From 9035240b4d5659cacdec3c235b29aecde06115e5 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 21 Jan 2026 15:16:23 +1100 Subject: [PATCH] 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 --- .env.example | 12 + .../pages/developers/self-hosting/how-to.mdx | 5 +- .../self-hosting/signing-certificate.mdx | 26 +- apps/remix/example/cert.p12 | Bin 2637 -> 2565 bytes docker/README.md | 110 +- package-lock.json | 982 ++++++++++-------- package.json | 3 +- .../include-document-certificate.spec.ts | 14 +- .../e2e/scenarios/form-flattening.spec.ts | 42 +- packages/lib/constants/app.ts | 9 + .../internal/seal-document.handler.ts | 165 ++- .../pdf/add-rejection-stamp-to-pdf.ts | 90 +- .../server-only/pdf/flatten-annotations.ts | 63 -- packages/lib/server-only/pdf/flatten-form.ts | 170 --- .../server-only/pdf/generate-audit-log-pdf.ts | 6 +- .../pdf/generate-certificate-pdf.ts | 16 +- .../pdf/insert-form-values-in-pdf.ts | 51 +- .../server-only/pdf/insert-image-in-pdf.ts | 26 - .../lib/server-only/pdf/insert-text-in-pdf.ts | 54 - packages/lib/server-only/pdf/normalize-pdf.ts | 16 +- .../pdf/normalize-signature-appearances.ts | 26 - .../lib/universal/upload/put-file.server.ts | 4 +- packages/lib/utils/env.ts | 14 +- packages/signing/constants/byte-range.ts | 3 - .../helpers/add-signing-placeholder.ts | 96 -- packages/signing/helpers/tsa.ts | 33 + .../update-signing-placeholder.test.ts | 72 -- .../helpers/update-signing-placeholder.ts | 39 - packages/signing/index.ts | 52 +- packages/signing/package.json | 6 +- .../signing/transports/google-cloud-hsm.ts | 79 -- packages/signing/transports/google-cloud.ts | 85 ++ packages/signing/transports/local-cert.ts | 80 -- packages/signing/transports/local.ts | 32 + packages/tsconfig/process-env.d.ts | 6 + render.yaml | 12 + turbo.json | 34 +- 37 files changed, 1065 insertions(+), 1468 deletions(-) delete mode 100644 packages/lib/server-only/pdf/flatten-annotations.ts delete mode 100644 packages/lib/server-only/pdf/flatten-form.ts delete mode 100644 packages/lib/server-only/pdf/insert-image-in-pdf.ts delete mode 100644 packages/lib/server-only/pdf/insert-text-in-pdf.ts delete mode 100644 packages/lib/server-only/pdf/normalize-signature-appearances.ts delete mode 100644 packages/signing/constants/byte-range.ts delete mode 100644 packages/signing/helpers/add-signing-placeholder.ts create mode 100644 packages/signing/helpers/tsa.ts delete mode 100644 packages/signing/helpers/update-signing-placeholder.test.ts delete mode 100644 packages/signing/helpers/update-signing-placeholder.ts delete mode 100644 packages/signing/transports/google-cloud-hsm.ts create mode 100644 packages/signing/transports/google-cloud.ts delete mode 100644 packages/signing/transports/local-cert.ts create mode 100644 packages/signing/transports/local.ts diff --git a/.env.example b/.env.example index 9f842a7f9..7e2b8cb34 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,18 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH= NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS= # OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport. NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS= +# OPTIONAL: The path to the certificate chain file for the gcloud-hsm signing transport. +NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH= +# OPTIONAL: The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport. +NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS= +# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. +NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH= +# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps). +NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY= +# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL. +NEXT_PUBLIC_SIGNING_CONTACT_INFO= +# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. +NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER= # [[STORAGE]] # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index 0d199b6b5..e12e05529 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -291,10 +291,13 @@ For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)]( | `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). | | `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | | `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | -| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) | +| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. | +| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. | +| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. | +| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). | | `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). | | `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. | diff --git a/apps/documentation/pages/developers/self-hosting/signing-certificate.mdx b/apps/documentation/pages/developers/self-hosting/signing-certificate.mdx index d655fa806..a8d12d305 100644 --- a/apps/documentation/pages/developers/self-hosting/signing-certificate.mdx +++ b/apps/documentation/pages/developers/self-hosting/signing-certificate.mdx @@ -53,15 +53,21 @@ Have the Certificate Authority sign the Certificate Signing Request. Configure your instance to use the new certificate by configuring the following environment variables in your `.env` file: -| Environment Variable | Description | -| :-------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm | -| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. | -| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. | -| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. | -| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. | -| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. | -| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. | -| `NEXT_PRIVATE_SIGNING_GCLOUD_ APPLICATION_CREDENTIALS_CONTENTS` | The Google Cloud Credentials file path for the gcloud-hsm signing transport. This field is optional. | +| Environment Variable | Description | +| :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm | +| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. This field is optional. | +| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. This field is optional. | +| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. This field is optional. | +| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. This field is optional. | diff --git a/apps/remix/example/cert.p12 b/apps/remix/example/cert.p12 index 532ee19abea35f767bc0fa2cdcfa81f8f5a71d9b..3ae58d11ea7e57c06eddad7c5148c3994362aaa5 100644 GIT binary patch delta 2503 zcmV;&2{`u66onK(FoFsJ0s#Xsf(gF{2`Yw2hW8Bt2LYgh39tl$39K-J38*lF1YZUT zDuzgg_YDCD2B3ljP%wf7OacJ_FoFa{kw6%K127H*2y(f(a7F={Edl}v0Dyu782!OG zR;FoT+?ztlO=JY4uFM7l5~!S-_F|~;60h5473qh*Cm%pwwVbZuoIh|0;YPEnql`F6 z1M@A}1-5;n{ClTia5(N`P*8HvsS#R3nH<<5y_Oz;ZyHt;1s9TXY|JJ?lsS!m=0j~Xa_Yl;yvBrDhd!@YBK0nQ4(=7=$Tqvx z(*gEl7J#BUz_D5dew4-HYW6ujmO(ig(9RhKH&%@Zy zL`&i`-7mYmJ{7b-0h3Zyrk;9pP5T>-WjLP6eo-n5D9v1>8I0a~Ob9~U5-Ny)?q((- zLGrx;xGXLnR8R1r)qA(>4jk_d+088?!K0tY+Ml2ml=-`gtu&ZQ+ zP7Tv_^YCp-O<0X_WA%n-9{b5o4@hj!KlqIx3)u=#sYtuory_S(H|(tYUkLCI*|72= z1CbR(PQ{Bm{olxnw2INbr$MO5*uaR8lHW1NbNlx$K*_4(J+x`{qT@?b%tefe0q^ zI~UW@XcursdWy8Bq19A_!VRb7gc)tK^lMidwwXLsdoa-C2(|u`(GA^v%Z|Ef6nj;{ zUo;8ji+!HXiTywd|M^-dl#Mq?CN(IsF-)(`dOzf$FE!OqwE>k)Uvn+G>2rdSB;7DC zTKKU&%9bhSN!GA6Yf;NR>(Y!n9$2J<1MqOBOBoBtdo?eeH@zFYA@-Z)n$G%9^0 z6jnV$vC$6`6ydrsm2OE54S`1R|*ocV`qqLj0z91c|1!Z>1m_?%n$xtYJm%}*Y zTt0rP%m2^4*E2ZI_@-3y0;DFJdO=snTU~z&lfP(-y zQS=q??(U{9b^JoPySg39%RKJ!Xz+wz5$}1~uOnqC4}a&c#xn?zf$cJ%vt~yj+R01N zDVF$c-=VBrtMMWo3|0e&(`_(VNJ!QXijLrE2cn3p+)RX*39+=BNBdpn8+PI?rPs#d z78pM!>Bb?>Xw6s=nXsg@fL~UhGNL;f&JjCUi!;1e=tm@$lvVqulXD^ZELLeZV}1J; z(aum4bANb@WigoY2B{An0ua=_?Qy&jXb>w>63tTxcdQ-mN_~J#69Kl9Lf+pEXrb19 z@qx2RoAKP-k^VEEwM~E{GdDd=X63#$bAL{fHh?QG**@r`rDQUYPZkhKAsbK9 z+N@(DorslzWQ&`d?Ektp0^z_{ z*Afr2Z-msXix|!3of`Zp&B{Q7*%AKz(?R!i{sSuqLh(7v1Ey!a^dSSPk-a zMt?bYPLTvkP5GG5EENz6q`PA2?we}k5c5d!S4PLp&?77JH8w?EIBLg>e%hGNb6kIo zq(lWJgH|~X9~3*&z!*)JepDyL2={OI<$CGe%aQ^ zwDa?L>V3X7f))AF0!Ifc4PO=%y+`reJAa1~(x^0!trB`_lf2`Yw2hW8Bt2^BFG1QdaG*cU7|ZJ;`Bf<$;F zeeFz+dDN>gIWQqG2?hl#4g&%j1povTq*02QMj3bhYDaOZtrG5%`iNFe`$cX0}4j8bjzU@bEFGNg&~gZpx8s~q-NhS zR_B@NfM(TX?x^Nlj#AP)di+@=6c@=WXM>h9)#1~2ZKCMImK-Jk;ZEx14ip~6@?!Zt9XBq2oe_~@FE_GOV1?=pVzj_)pC5x?sRYO&U5M7` zvh&4*8Gy|gtcZ7NOuQu6;OYSwThxcf;zCYN5zWQkDYeLem?OC}X$?PTnWc^OSkX1m zWBxiTK{jX-?Vfi|F#dexa((0(i!?{Ikds;u8YzDWznc9kem72Dgd2i>J~LH2m*%O0e}c3x zy8N0(C)YiTbUntum)ec7hhv6bAK>h(O#F=*Y;@2}75HvG&Gl)TVabG@4MZ!Zyfad}1S{9!c8^dp z111?ZR~ibVFTj8vTW~m-Ob7I1GaRo1#(D(+x4C&?6m+vu@~b|Po$9~PP}sk-fxJrC z{&X`XW9U9QKVYkM9ps&K!}8VNXsW6|gBeNLe|GrCs6^vob|50|XeP6Ns#IPXp|@#5 z(#|z1b-Fq?z6aT^=g>Anppvz%Vau8ZV<~pJO7ilFpM$3ZP43{Od<$el2BvXm(?85w zy_AB80COR~46S{sRT94KY?+z1O{n-GDcWsCk{J5yP4LtP@GvN8T7maUdw-Rtm?h{EfG{AI}y}o|D=( zBESv9ty#Ku963Kk$Xe%w;ZK<`AWZ|xx4mhORL@f1p25{h90ZMj8(XHPUTM_v)dKt; z5xRf;y4-x)r-B`N%PS1?iO-*hsRtmJ!qJ?ReVF;{rlm20{9Gl>wv5lW1vW`KuL7f_DM;u)TIbTeUJ1654 zu;Ibh>*;kz9t+-oe)iw9y;m|d#jR2=O_kTfA=##0m*MvK7+u4LfjS*7nm-A65;4HQ zu%=~oBT`IOAoPc-3(pr2|8cR=^>m2Aq^_4ZU_@@k)2u;@7k29rhDmt&KK1V(?wr;eEG zk(2WR0tf&Ef&|E5ztSv7$6C##>@1z+gQqf9hIdz+&9qc8WWF)n`$LF)KxNcfY)kucq8vknN|l?SITHHj?xL!swOHAEE*jPw>v0{31T? zxXQ3ofWIhf4l>?bJkB;sr$m2t!ksmeL>545??X0Os_5i**gwL8t*c^=H5wxSl;~2u zv$Dq<@a7Hid&hg7+@U?+OUYALug?Eu3VK^&WZvr)U^KCU@VfWaeLc_Gy4@#p25T+Q z%Jxs<1jJdlSbSC(E({1WXYe+tFdaK4>vwJOmPEZ%T6qQ>sN6z?zn*TbjyDMK1$AxRvzg z%TptcFiQ0fAsVp$lE?BpGb2`7}$WSi8Nzj*#D^a>o?Hs+V2H$YftKfcp0s+P6z;SfU+NHoW zVHge5`8Gbb<0gOCP!d64OT;0@K>;O5FIu+(@_~IT_rlUWi+I<8n$~t6Ir$n~;L#Mf zHS71~7(#LFzVlmTdsRQxk<@7(GJpD;LrbN%K_Jnv@q8b*Vf@vzv^!hXCdC4rhc^v0 z^95TksKRUno;k$_mF6?vRsI*=_rf-wlzH!1hy%$RXz_pl4_)6ko~!n+AGfbezq!{ zoTACGZ5DqSTb7X@7omUhZw2L}95A?2Uoh%fRlm8-l@L79XwWT|6&z+5VFwAJAc7GtW}I25h?AKIKsN-YP;agvBM!?X-TcXq zdL}b!qIV>w^hkVXH+?Pbf*2}H%*(N<`59IVS7Y*Z)*7XpoBuwPoaK#8nz@KNK<8dS zy3v2lm>4L8>LB+`Mn|9Wpc;e8V2H}fjQukHO)!AoIku{TUa(K2Ca(|3+W*nl|Mihu z!$e-=#)z1-iWz74h0JlN0trf8tYv>|D%}uv6SIkz@zmcXQkST8#Yf$Y(~rlDF(oh~ z1_>&LNQUDh$bvpzh2c}M&R{?y1mFflM8FbM_)D-Ht!8U+9Z m6pQ|$NLonsKE=9Dfk~#|tq7KrH3SF`55=aRiz2520tf&*@Y{I+ diff --git a/docker/README.md b/docker/README.md index 087fb895b..a493aac9b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -42,16 +42,17 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="" 4. Set up your signing certificate. You have three options: **Option A: Generate Certificate Inside Container (Recommended)** - + Start your containers first, then generate a self-signed certificate: + ```bash # Start containers docker-compose up -d - + # Set certificate password securely (won't appear in command history) read -s -p "Enter certificate password: " CERT_PASS echo - + # Generate certificate inside container using environment variable docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c " openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ @@ -63,19 +64,19 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="" -passout env:CERT_PASS && \ rm /tmp/private.key /tmp/certificate.crt " - + # Restart container docker-compose restart documenso ``` - + **Option B: Use Existing Certificate** - + If you have an existing `.p12` certificate, update the volume binding in `compose.yml`: + ```yaml volumes: - /path/to/your/cert.p12:/opt/documenso/cert.p12:ro ``` - 5. Run the following command to start the containers: @@ -157,7 +158,6 @@ If you encounter errors related to certificate access, here are common solutions docker exec -it ls -la /opt/documenso/cert.p12 ``` - ### Container Logs Check application logs for detailed error information: @@ -202,45 +202,55 @@ The environment variables listed above are a subset of those that are available Here's a markdown table documenting all the provided environment variables: -| Variable | Description | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `PORT` | The port to run the Documenso application on, defaults to `3000`. | -| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. | -| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). | -| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). | -| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). | -| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). | -| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. | -| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | -| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | -| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) | -| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. | -| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. | -| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. | -| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). | -| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). | -| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. | -| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). | -| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. | -| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. | -| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. | -| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). | -| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. | -| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. | -| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. | -| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. | -| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. | -| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. | -| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. | -| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) | -| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. | -| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. | -| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. | -| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. | -| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. | -| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. | -| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. | -| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. | -| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). | -| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. | -| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. | +| Variable | Description | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `PORT` | The port to run the Documenso application on, defaults to `3000`. | +| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. | +| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). | +| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). | +| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). | +| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). | +| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. | +| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | +| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | +| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm | +| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file for the gcloud-hsm transport. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm transport. | +| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm transport. | +| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). | +| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. | +| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. | +| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). | +| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). | +| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. | +| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). | +| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. | +| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. | +| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. | +| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). | +| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. | +| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. | +| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. | +| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. | +| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. | +| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. | +| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. | +| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) | +| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. | +| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. | +| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. | +| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. | +| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. | +| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. | +| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. | +| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. | +| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). | +| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. | +| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. | diff --git a/package-lock.json b/package-lock.json index 2f1c80485..c1cb990bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@ai-sdk/google-vertex": "3.0.81", "@documenso/pdf-sign": "^0.1.0", "@documenso/prisma": "*", + "@libpdf/core": "^0.1.0", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", @@ -3235,6 +3236,30 @@ "tslib": "2" } }, + "node_modules/@google-cloud/kms": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-5.2.1.tgz", + "integrity": "sha512-IE1HdWGymB7/gGtGbevbpHfZZSUT/xKmpJUWIJYJoJ1QHHYH5jdrl5jsIfMo8kyWAErP7nwgwU++LaUZBhXL9A==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/secret-manager": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-6.1.1.tgz", + "integrity": "sha512-dwSuxJ9RNmAW46FjK1StiNIeOiSHHQs/XIy4VArJ6bBMR+WsIvR+zhPh2pa40aFa9uTty67j38Rl268TVV62EA==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@graphql-typed-document-node/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", @@ -4260,6 +4285,73 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, + "node_modules/@libpdf/core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.1.0.tgz", + "integrity": "sha512-VXxnNfYW9OED+XIBi9PVms3ahnK7DEUZRvkQdHJwZTV/iO9F7zfwvWRLjKy0Tg6h5CIzETgQgkE1kyEbq+2PsA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "@scure/base": "^2.0.0", + "pako": "^2.1.0", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@google-cloud/kms": "^5.0.0", + "@google-cloud/secret-manager": "^6.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/kms": { + "optional": true + }, + "@google-cloud/secret-manager": { + "optional": true + } + } + }, + "node_modules/@libpdf/core/node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@libpdf/core/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@libpdf/core/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@libpdf/core/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/@lingui/babel-plugin-extract-messages": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/@lingui/babel-plugin-extract-messages/-/babel-plugin-extract-messages-5.6.0.tgz", @@ -12097,6 +12189,16 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@playwright/browser-chromium": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.56.1.tgz", @@ -16263,6 +16365,15 @@ "unist-util-visit": "^5.0.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.3.0.tgz", @@ -16579,17 +16690,6 @@ "@types/node": "*" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -16887,13 +16987,6 @@ "@types/ms": "*" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -17706,145 +17799,6 @@ "node": ">= 20" } }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vvo/tzdb": { "version": "6.196.0", "resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.196.0.tgz", @@ -18303,16 +18257,6 @@ "node": ">=12.0.0" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -18844,6 +18788,15 @@ "node": ">= 0.8" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -19050,23 +19003,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -19125,16 +19061,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -21061,6 +20987,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -21216,16 +21151,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -23463,16 +23388,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -23726,6 +23641,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", @@ -23966,6 +23904,18 @@ "node": ">=0.4.x" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -24521,6 +24471,223 @@ "node": ">=14" } }, + "node_modules/google-gax": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.6.tgz", + "integrity": "sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "^10.1.0", + "google-logging-utils": "^1.1.1", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^3.0.0", + "protobufjs": "^7.5.3", + "retry-request": "^8.0.0", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/google-gax/node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/google-gax/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/google-gax/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/google-gax/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/google-logging-utils": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", @@ -25173,6 +25340,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -27280,13 +27473,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -29367,6 +29553,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -30476,16 +30682,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pause-stream": { "version": "0.0.11", "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", @@ -30772,6 +30968,35 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pkijs": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz", + "integrity": "sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/playwright": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", @@ -31422,6 +31647,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -32895,6 +33132,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/retry-request": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz", + "integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -33533,13 +33783,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -33949,13 +34192,6 @@ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "license": "MIT" }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/start-server-and-test": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.3.tgz", @@ -34006,13 +34242,6 @@ "node": ">= 0.8" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -34047,6 +34276,15 @@ "duplexer": "~0.1.1" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -34352,26 +34590,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/stripe": { "version": "12.18.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-12.18.0.tgz", @@ -34412,6 +34630,12 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -34902,6 +35126,64 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/teeny-request": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz", + "integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/temporal-polyfill": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.2.5.tgz", @@ -35014,13 +35296,6 @@ "esm": "^3.2.25" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -35046,36 +35321,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/title": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/title/-/title-4.0.1.tgz", @@ -36379,93 +36624,6 @@ } } }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -36563,6 +36721,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", @@ -36676,23 +36843,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wicked-good-xpath": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", @@ -37547,12 +37697,12 @@ "version": "0.0.0", "license": "AGPLv3", "dependencies": { - "@documenso/pdf-sign": "^0.1.0", - "@documenso/tsconfig": "*", + "@google-cloud/kms": "^5.2.1", + "@google-cloud/secret-manager": "^6.1.1", "ts-pattern": "^5.9.0" }, "devDependencies": { - "vitest": "^3.2.4" + "@documenso/tsconfig": "*" } }, "packages/tailwind-config": { diff --git a/package.json b/package.json index 7dff318d6..e042f76ad 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@ai-sdk/google-vertex": "3.0.81", "@documenso/pdf-sign": "^0.1.0", "@documenso/prisma": "*", + "@libpdf/core": "^0.1.0", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", @@ -103,4 +104,4 @@ "typescript": "5.6.2", "zod": "^3.25.76" } -} \ No newline at end of file +} diff --git a/packages/app-tests/e2e/features/include-document-certificate.spec.ts b/packages/app-tests/e2e/features/include-document-certificate.spec.ts index 3e3a41856..709a3083b 100644 --- a/packages/app-tests/e2e/features/include-document-certificate.spec.ts +++ b/packages/app-tests/e2e/features/include-document-certificate.spec.ts @@ -1,4 +1,4 @@ -import { PDFDocument } from '@cantoo/pdf-lib'; +import { PDF } from '@libpdf/core'; import { expect, test } from '@playwright/test'; import { DocumentStatus, FieldType } from '@prisma/client'; @@ -43,7 +43,7 @@ test.describe('Signing Certificate Tests', () => { return fetch(documentUrl).then(async (res) => await res.arrayBuffer()); }); - const originalPdf = await PDFDocument.load(documentData); + const originalPdf = await PDF.load(new Uint8Array(documentData)); // Sign the document await page.goto(`/sign/${recipient.token}`); @@ -101,7 +101,7 @@ test.describe('Signing Certificate Tests', () => { const completedDocumentData = new Uint8Array(pdfData); // Load the PDF and check number of pages - const pdfDoc = await PDFDocument.load(completedDocumentData); + const pdfDoc = await PDF.load(new Uint8Array(completedDocumentData)); expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate }); @@ -153,7 +153,7 @@ test.describe('Signing Certificate Tests', () => { return fetch(documentUrl).then(async (res) => await res.arrayBuffer()); }); - const originalPdf = await PDFDocument.load(documentData); + const originalPdf = await PDF.load(new Uint8Array(documentData)); // Sign the document await page.goto(`/sign/${recipient.token}`); @@ -206,7 +206,7 @@ test.describe('Signing Certificate Tests', () => { const completedDocumentData = new Uint8Array(pdfData); // Load the PDF and check number of pages - const completedPdf = await PDFDocument.load(completedDocumentData); + const completedPdf = await PDF.load(new Uint8Array(completedDocumentData)); expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate }); @@ -258,7 +258,7 @@ test.describe('Signing Certificate Tests', () => { return fetch(documentUrl).then(async (res) => await res.arrayBuffer()); }); - const originalPdf = await PDFDocument.load(new Uint8Array(documentData)); + const originalPdf = await PDF.load(new Uint8Array(documentData)); // Sign the document await page.goto(`/sign/${recipient.token}`); @@ -309,7 +309,7 @@ test.describe('Signing Certificate Tests', () => { ); // Load the PDF and check number of pages - const completedPdf = await PDFDocument.load(completedDocumentData); + const completedPdf = await PDF.load(new Uint8Array(completedDocumentData)); expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount()); }); diff --git a/packages/app-tests/e2e/scenarios/form-flattening.spec.ts b/packages/app-tests/e2e/scenarios/form-flattening.spec.ts index 1e7e2db51..06167a647 100644 --- a/packages/app-tests/e2e/scenarios/form-flattening.spec.ts +++ b/packages/app-tests/e2e/scenarios/form-flattening.spec.ts @@ -1,4 +1,4 @@ -import { PDFDocument } from '@cantoo/pdf-lib'; +import { PDF } from '@libpdf/core'; import { expect, test } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; @@ -39,24 +39,30 @@ const TEST_FORM_VALUES = { * Returns true if the PDF has form fields, false if they've been flattened. */ async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise { - const pdfDoc = await PDFDocument.load(pdfBuffer); + const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer)); - const form = pdfDoc.getForm(); - const fields = form.getFields(); + const form = await pdfDoc.getForm(); - return fields.length > 0; + if (!form) { + return false; + } + + return form.fieldCount > 0; } /** * Helper to get form field names from a PDF. */ async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise { - const pdfDoc = await PDFDocument.load(pdfBuffer); + const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer)); - const form = pdfDoc.getForm(); - const fields = form.getFields(); + const form = await pdfDoc.getForm(); - return fields.map((field) => field.getName()); + if (!form) { + return []; + } + + return form.getFieldNames(); } /** @@ -66,17 +72,21 @@ async function getPdfTextFieldValue( pdfBuffer: Uint8Array, fieldName: string, ): Promise { - const pdfDoc = await PDFDocument.load(pdfBuffer); + const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer)); - const form = pdfDoc.getForm(); + const form = await pdfDoc.getForm(); - try { - const textField = form.getTextField(fieldName); - - return textField.getText() ?? ''; - } catch { + if (!form) { return undefined; } + + const textField = form.getTextField(fieldName); + + if (!textField) { + return undefined; + } + + return textField.getValue(); } test.describe.configure({ diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index 452ec68a0..84c63d7a6 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -6,6 +6,12 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000'; +export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () => + env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL(); + +export const NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER = () => + env('NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER') === 'true'; + export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () => env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL(); @@ -30,3 +36,6 @@ export const IS_AI_FEATURES_CONFIGURED = () => */ export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true'; + +export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => + env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY'); diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index d4148cfc7..b46996e51 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -1,12 +1,5 @@ -import { - PDFDocument, - RotationTypes, - popGraphicsState, - pushGraphicsState, - radiansToDegrees, - rotateDegrees, - translate, -} from '@cantoo/pdf-lib'; +import { PDFDocument } from '@cantoo/pdf-lib'; +import { PDF } from '@libpdf/core'; import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client'; import { DocumentStatus, @@ -18,8 +11,8 @@ import { import { nanoid } from 'nanoid'; import path from 'node:path'; import { groupBy } from 'remeda'; -import { match } from 'ts-pattern'; +import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf'; import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf'; import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; import { prisma } from '@documenso/prisma'; @@ -31,14 +24,9 @@ import { AppError, AppErrorCode } from '../../../errors/app-error'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; -import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; -import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations'; -import { flattenForm } from '../../../server-only/pdf/flatten-form'; -import { getPageSize } from '../../../server-only/pdf/get-page-size'; import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1'; import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2'; import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf'; -import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances'; import { getTeamSettings } from '../../../server-only/team/get-team-settings'; import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; @@ -181,8 +169,8 @@ export const run = async ({ }); } - let certificateDoc: PDFDocument | null = null; - let auditLogDoc: PDFDocument | null = null; + let certificateDoc: PDF | null = null; + let auditLogDoc: PDF | null = null; if (settings.includeSigningCertificate || settings.includeAuditLog) { const certificatePayload = { @@ -208,7 +196,7 @@ export const run = async ({ ? getCertificatePdf({ documentId, language: envelope.documentMeta.language, - }).then(async (buffer) => PDFDocument.load(buffer)) + }).then(async (buffer) => PDF.load(buffer)) : generateCertificatePdf(certificatePayload); const makeAuditLogPdf = async () => @@ -216,7 +204,7 @@ export const run = async ({ ? getAuditLogsPdf({ documentId, language: envelope.documentMeta.language, - }).then(async (buffer) => PDFDocument.load(buffer)) + }).then(async (buffer) => PDF.load(buffer)) : generateAuditLogPdf(certificatePayload); const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([ @@ -342,8 +330,8 @@ type DecorateAndSignPdfOptions = { envelopeItemFields: Field[]; isRejected: boolean; rejectionReason: string; - certificateDoc: PDFDocument | null; - auditLogDoc: PDFDocument | null; + certificateDoc: PDF | null; + auditLogDoc: PDF | null; }; /** @@ -360,48 +348,47 @@ const decorateAndSignPdf = async ({ }: DecorateAndSignPdfOptions) => { const pdfData = await getFileServerSide(envelopeItem.documentData); - const pdfDoc = await PDFDocument.load(pdfData); + let pdfDoc = await PDF.load(pdfData); // Normalize and flatten layers that could cause issues with the signature - normalizeSignatureAppearances(pdfDoc); - await flattenForm(pdfDoc); - flattenAnnotations(pdfDoc); + pdfDoc.flattenAll(); + // Upgrade to PDF 1.7 for better compatibility with signing + pdfDoc.upgradeVersion('1.7'); // Add rejection stamp if the document is rejected - if (isRejected && rejectionReason) { + if (isRejected) { await addRejectionStampToPdf(pdfDoc, rejectionReason); } if (certificateDoc) { - const certificatePages = await pdfDoc.copyPages( + await pdfDoc.copyPagesFrom( certificateDoc, - certificateDoc.getPageIndices(), + Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index), ); - - certificatePages.forEach((page) => { - pdfDoc.addPage(page); - }); } if (auditLogDoc) { - const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices()); - - auditLogPages.forEach((page) => { - pdfDoc.addPage(page); - }); + await pdfDoc.copyPagesFrom( + auditLogDoc, + Array.from({ length: auditLogDoc.getPageCount() }, (_, index) => index), + ); } // Handle V1 and legacy insertions. if (envelope.internalVersion === 1) { + const legacy_pdfLibDoc = await PDFDocument.load(await pdfDoc.save({ useXRefStream: true })); + for (const field of envelopeItemFields) { if (field.inserted) { if (envelope.useLegacyFieldInsertion) { - await legacy_insertFieldInPDF(pdfDoc, field); + await legacy_insertFieldInPDF(legacy_pdfLibDoc, field); } else { - await insertFieldInPDFV1(pdfDoc, field); + await insertFieldInPDFV1(legacy_pdfLibDoc, field); } } } + + await pdfDoc.reload(await legacy_pdfLibDoc.save()); } // Handle V2 envelope insertions. @@ -410,87 +397,61 @@ const decorateAndSignPdf = async ({ for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) { const page = pdfDoc.getPage(Number(pageNumber) - 1); - const pageRotation = page.getRotation(); - let { width: pageWidth, height: pageHeight } = getPageSize(page); - - let pageRotationInDegrees = match(pageRotation.type) - .with(RotationTypes.Degrees, () => pageRotation.angle) - .with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle)) - .exhaustive(); - - // Round to the closest multiple of 90 degrees. - pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90; - - // PDFs can have pages that are rotated, which are correctly rendered in the frontend. - // However when we load the PDF in the backend, the rotation is applied. - // To account for this, we swap the width and height for pages that are rotated by 90/270 - // degrees. This is so we can calculate the virtual position the field was placed if it - // was correctly oriented in the frontend. - if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) { - [pageWidth, pageHeight] = [pageHeight, pageWidth]; + if (!page) { + throw new Error(`Page ${pageNumber} does not exist`); } - // Rotate the page to the orientation that the react-pdf renders on the frontend. - // Note: These transformations are undone at the end of the function. - // If you change this if statement, update the if statement at the end as well - if (pageRotationInDegrees !== 0) { - let translateX = 0; - let translateY = 0; + const pageWidth = page.width; + const pageHeight = page.height; - switch (pageRotationInDegrees) { - case 90: - translateX = pageHeight; - translateY = 0; - break; - case 180: - translateX = pageWidth; - translateY = pageHeight; - break; - case 270: - translateX = 0; - translateY = pageWidth; - break; - case 0: - default: - translateX = 0; - translateY = 0; - } - - page.pushOperators(pushGraphicsState()); - page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees)); - } - - const renderedPdfOverlay = await insertFieldInPDFV2({ + const overlayBytes = await insertFieldInPDFV2({ pageWidth, pageHeight, fields, }); - const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay); + const overlayPdf = await PDF.load(overlayBytes); - // Draw the SVG on the page - page.drawPage(embeddedPage, { - x: 0, - y: 0, - width: pageWidth, - height: pageHeight, - }); + const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0); - // Remove the transformations applied to the page if any were applied. - if (pageRotationInDegrees !== 0) { - page.pushOperators(popGraphicsState()); + // Rotate the page to the orientation that the react-pdf renders on the frontend. + let translateX = 0; + let translateY = 0; + + switch (page.rotation) { + case 90: + translateX = pageHeight; + translateY = 0; + break; + case 180: + translateX = pageWidth; + translateY = pageHeight; + break; + case 270: + translateX = 0; + translateY = pageWidth; + break; } + + // Draw the overlay on the page + page.drawPage(embeddedPage, { + x: translateX, + y: translateY, + rotate: { + angle: page.rotation, + }, + }); } } // Re-flatten the form to handle our checkbox and radio fields that // create native arcoFields - await flattenForm(pdfDoc); + pdfDoc.flattenAll(); - const pdfBytes = await pdfDoc.save(); + pdfDoc = await PDF.load(await pdfDoc.save({ useXRefStream: true })); - const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) }); + const pdfBytes = await signPdf({ pdf: pdfDoc }); const { name } = path.parse(envelopeItem.title); @@ -500,7 +461,7 @@ const decorateAndSignPdf = async ({ const newDocumentData = await putPdfFileServerSide({ name: `${name}${suffix}`, type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(pdfBuffer), + arrayBuffer: async () => Promise.resolve(pdfBytes), }); return { diff --git a/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts index f739cac44..b80aa0249 100644 --- a/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts +++ b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts @@ -1,88 +1,72 @@ -import type { PDFDocument } from '@cantoo/pdf-lib'; -import { TextAlignment, rgb, setFontAndSize } from '@cantoo/pdf-lib'; -import fontkit from '@pdf-lib/fontkit'; +import { type PDF, rgb } from '@libpdf/core'; import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app'; -import { getPageSize } from './get-page-size'; /** * Adds a rejection stamp to each page of a PDF document. * The stamp is placed in the center of the page. */ -export async function addRejectionStampToPdf( - pdfDoc: PDFDocument, - reason: string, -): Promise { - const pages = pdfDoc.getPages(); - pdfDoc.registerFontkit(fontkit); +export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise { + const pages = pdf.getPages(); const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then( async (res) => res.arrayBuffer(), ); - const font = await pdfDoc.embedFont(fontBytes, { - customName: 'Noto', - }); + const font = pdf.embedFont(new Uint8Array(fontBytes)); - const form = pdfDoc.getForm(); - - for (let i = 0; i < pages.length; i++) { - const page = pages[i]; - const { width, height } = getPageSize(page); + for (const page of pages) { + const height = page.height; + const width = page.width; // Draw the "REJECTED" text const rejectedTitleText = 'DOCUMENT REJECTED'; const rejectedTitleFontSize = 36; - const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`); - - if (!rejectedTitleTextField.acroField.getDefaultAppearance()) { - rejectedTitleTextField.acroField.setDefaultAppearance( - setFontAndSize('Noto', rejectedTitleFontSize).toString(), - ); - } - - rejectedTitleTextField.updateAppearances(font); - - rejectedTitleTextField.setFontSize(rejectedTitleFontSize); - rejectedTitleTextField.setText(rejectedTitleText); - rejectedTitleTextField.setAlignment(TextAlignment.Center); - - const rejectedTitleTextWidth = - font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2; - const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize); + const rotationAngle = 45; // Calculate the center position of the page const centerX = width / 2; const centerY = height / 2; - // Position the title text at the center of the page - const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2; - const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2; + const widthOfText = font.getTextWidth(rejectedTitleText, rejectedTitleFontSize); // Add padding for the rectangle const padding = 20; + const rectWidth = widthOfText + padding; + const rectHeight = rejectedTitleFontSize + padding; + + const rectX = centerX - rectWidth / 2; + const rectY = centerY - rectHeight / 4; - // Draw the stamp background page.drawRectangle({ - x: rejectedTitleTextX - padding / 2, - y: rejectedTitleTextY - padding / 2, - width: rejectedTitleTextWidth + padding, - height: rejectedTitleTextHeight + padding, + x: rectX, + y: rectY, + width: rectWidth, + height: rectHeight, borderColor: rgb(220 / 255, 38 / 255, 38 / 255), borderWidth: 4, + rotate: { + angle: rotationAngle, + origin: 'center', + }, }); - rejectedTitleTextField.addToPage(page, { - x: rejectedTitleTextX, - y: rejectedTitleTextY, - width: rejectedTitleTextWidth, - height: rejectedTitleTextHeight, - textColor: rgb(220 / 255, 38 / 255, 38 / 255), - backgroundColor: undefined, - borderWidth: 0, - borderColor: undefined, + const textX = centerX - widthOfText / 2; + const textY = centerY; + + // Draw the text centered within the rectangle + page.drawText(rejectedTitleText, { + x: textX, + y: textY, + size: rejectedTitleFontSize, + font, + color: rgb(220 / 255, 38 / 255, 38 / 255), + rotate: { + angle: rotationAngle, + origin: 'center', + }, }); } - return pdfDoc; + return pdf; } diff --git a/packages/lib/server-only/pdf/flatten-annotations.ts b/packages/lib/server-only/pdf/flatten-annotations.ts deleted file mode 100644 index 6ceb2f201..000000000 --- a/packages/lib/server-only/pdf/flatten-annotations.ts +++ /dev/null @@ -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); - }); - } -}; diff --git a/packages/lib/server-only/pdf/flatten-form.ts b/packages/lib/server-only/pdf/flatten-form.ts deleted file mode 100644 index 3c27751a3..000000000 --- a/packages/lib/server-only/pdf/flatten-form.ts +++ /dev/null @@ -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); - } -}; diff --git a/packages/lib/server-only/pdf/generate-audit-log-pdf.ts b/packages/lib/server-only/pdf/generate-audit-log-pdf.ts index 4f257fc80..aac8245db 100644 --- a/packages/lib/server-only/pdf/generate-audit-log-pdf.ts +++ b/packages/lib/server-only/pdf/generate-audit-log-pdf.ts @@ -1,3 +1,4 @@ +import { PDF } from '@libpdf/core'; import { i18n } from '@lingui/core'; import { prisma } from '@documenso/prisma'; @@ -7,7 +8,6 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs'; import { getTranslations } from '../../utils/i18n'; import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims'; import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf'; -import { mergeFilesIntoPdf } from './generate-certificate-pdf'; import { renderAuditLogs } from './render-audit-logs'; type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & { @@ -43,7 +43,9 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) = i18n, }); - return await mergeFilesIntoPdf(auditLogPages); + return await PDF.merge(auditLogPages, { + includeAnnotations: true, + }); }; const getAuditLogs = async (envelopeId: string) => { diff --git a/packages/lib/server-only/pdf/generate-certificate-pdf.ts b/packages/lib/server-only/pdf/generate-certificate-pdf.ts index ba302b2a3..ff12edc91 100644 --- a/packages/lib/server-only/pdf/generate-certificate-pdf.ts +++ b/packages/lib/server-only/pdf/generate-certificate-pdf.ts @@ -1,4 +1,4 @@ -import { PDFDocument } from '@cantoo/pdf-lib'; +import { PDF } from '@libpdf/core'; import { i18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import type { DocumentMeta } from '@prisma/client'; @@ -144,17 +144,5 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti const certificatePages = await renderCertificate(payload); - return await mergeFilesIntoPdf(certificatePages); + return await PDF.merge(certificatePages); }; - -export async function mergeFilesIntoPdf(buffers: Uint8Array[]) { - const mergedPdf = await PDFDocument.create(); - - for (const buffer of buffers) { - const pdf = await PDFDocument.load(buffer); - const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()); - pages.forEach((p) => mergedPdf.addPage(p)); - } - - return mergedPdf; -} diff --git a/packages/lib/server-only/pdf/insert-form-values-in-pdf.ts b/packages/lib/server-only/pdf/insert-form-values-in-pdf.ts index a79f3b504..2139c5cd3 100644 --- a/packages/lib/server-only/pdf/insert-form-values-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-form-values-in-pdf.ts @@ -1,10 +1,4 @@ -import { - PDFCheckBox, - PDFDocument, - PDFDropdown, - PDFRadioGroup, - PDFTextField, -} from '@cantoo/pdf-lib'; +import { PDF } from '@libpdf/core'; export type InsertFormValuesInPdfOptions = { pdf: Buffer; @@ -12,7 +6,7 @@ export type InsertFormValuesInPdfOptions = { }; export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => { - const doc = await PDFDocument.load(pdf); + const doc = await PDF.load(pdf); const form = doc.getForm(); @@ -20,41 +14,12 @@ export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValue return pdf; } - for (const [key, value] of Object.entries(formValues)) { - try { - const field = form.getField(key); + const filledForm = Object.entries(formValues).map(([key, value]) => [ + key, + typeof value === 'boolean' ? value : value.toString(), + ]); - if (!field) { - continue; - } + form.fill(Object.fromEntries(filledForm)); - if (typeof value === 'boolean' && field instanceof PDFCheckBox) { - if (value) { - field.check(); - } else { - field.uncheck(); - } - } - - if (field instanceof PDFTextField) { - field.setText(value.toString()); - } - - if (field instanceof PDFDropdown) { - field.select(value.toString()); - } - - if (field instanceof PDFRadioGroup) { - field.select(value.toString()); - } - } catch (err) { - if (err instanceof Error) { - console.error(`Error setting value for field ${key}: ${err.message}`); - } else { - console.error(`Error setting value for field ${key}`); - } - } - } - - return await doc.save().then((buf) => Buffer.from(buf)); + return await doc.save({ incremental: true }).then((buf) => Buffer.from(buf)); }; diff --git a/packages/lib/server-only/pdf/insert-image-in-pdf.ts b/packages/lib/server-only/pdf/insert-image-in-pdf.ts deleted file mode 100644 index d896ab668..000000000 --- a/packages/lib/server-only/pdf/insert-image-in-pdf.ts +++ /dev/null @@ -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 { - 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'); -} diff --git a/packages/lib/server-only/pdf/insert-text-in-pdf.ts b/packages/lib/server-only/pdf/insert-text-in-pdf.ts deleted file mode 100644 index 7dda44db9..000000000 --- a/packages/lib/server-only/pdf/insert-text-in-pdf.ts +++ /dev/null @@ -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 { - // 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'); -} diff --git a/packages/lib/server-only/pdf/normalize-pdf.ts b/packages/lib/server-only/pdf/normalize-pdf.ts index 57993642e..4a2ebcd5d 100644 --- a/packages/lib/server-only/pdf/normalize-pdf.ts +++ b/packages/lib/server-only/pdf/normalize-pdf.ts @@ -1,13 +1,11 @@ -import { PDFDocument } from '@cantoo/pdf-lib'; +import { PDF } from '@libpdf/core'; import { AppError } from '../../errors/app-error'; -import { flattenAnnotations } from './flatten-annotations'; -import { flattenForm, removeOptionalContentGroups } from './flatten-form'; export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => { const shouldFlattenForm = options.flattenForm ?? true; - const pdfDoc = await PDFDocument.load(pdf).catch((e) => { + const pdfDoc = await PDF.load(pdf).catch((e) => { console.error(`PDF normalization error: ${e.message}`); throw new AppError('INVALID_DOCUMENT_FILE', { @@ -21,11 +19,13 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean }); } - removeOptionalContentGroups(pdfDoc); + pdfDoc.flattenLayers(); - if (shouldFlattenForm) { - await flattenForm(pdfDoc); - flattenAnnotations(pdfDoc); + const form = pdfDoc.getForm(); + + if (shouldFlattenForm && form) { + form.flatten(); + pdfDoc.flattenAnnotations(); } return Buffer.from(await pdfDoc.save()); diff --git a/packages/lib/server-only/pdf/normalize-signature-appearances.ts b/packages/lib/server-only/pdf/normalize-signature-appearances.ts deleted file mode 100644 index b76a88f63..000000000 --- a/packages/lib/server-only/pdf/normalize-signature-appearances.ts +++ /dev/null @@ -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); - } - }); - } - } -}; diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index 3ecce8a3b..5d7e0fe26 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -1,4 +1,4 @@ -import { PDFDocument } from '@cantoo/pdf-lib'; +import { PDF } from '@libpdf/core'; import { DocumentDataType } from '@prisma/client'; import { base64 } from '@scure/base'; import { match } from 'ts-pattern'; @@ -25,7 +25,7 @@ export const putPdfFileServerSide = async (file: File) => { const arrayBuffer = await file.arrayBuffer(); - const pdf = await PDFDocument.load(arrayBuffer).catch((e) => { + const pdf = await PDF.load(new Uint8Array(arrayBuffer)).catch((e) => { console.error(`PDF upload parse error: ${e.message}`); throw new AppError('INVALID_DOCUMENT_FILE'); diff --git a/packages/lib/utils/env.ts b/packages/lib/utils/env.ts index 26f90f0cc..c6780d58f 100644 --- a/packages/lib/utils/env.ts +++ b/packages/lib/utils/env.ts @@ -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 keyof NodeJS.ProcessEnv + ? NodeJS.ProcessEnv[K] + : string | undefined; -export const env = (variable: EnvironmentVariable | (string & object)): string | undefined => { +export const env = (variable: K): EnvValue => { 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; } - 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; }; export const createPublicEnv = () => diff --git a/packages/signing/constants/byte-range.ts b/packages/signing/constants/byte-range.ts deleted file mode 100644 index ee3f23814..000000000 --- a/packages/signing/constants/byte-range.ts +++ /dev/null @@ -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 = '**********'; diff --git a/packages/signing/helpers/add-signing-placeholder.ts b/packages/signing/helpers/add-signing-placeholder.ts deleted file mode 100644 index 197d7cdfc..000000000 --- a/packages/signing/helpers/add-signing-placeholder.ts +++ /dev/null @@ -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 })); -}; diff --git a/packages/signing/helpers/tsa.ts b/packages/signing/helpers/tsa.ts new file mode 100644 index 000000000..0aa8b4731 --- /dev/null +++ b/packages/signing/helpers/tsa.ts @@ -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)]; +}; diff --git a/packages/signing/helpers/update-signing-placeholder.test.ts b/packages/signing/helpers/update-signing-placeholder.test.ts deleted file mode 100644 index 8adc7d44f..000000000 --- a/packages/signing/helpers/update-signing-placeholder.test.ts +++ /dev/null @@ -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]); - }); -}); diff --git a/packages/signing/helpers/update-signing-placeholder.ts b/packages/signing/helpers/update-signing-placeholder.ts deleted file mode 100644 index 18875737d..000000000 --- a/packages/signing/helpers/update-signing-placeholder.ts +++ /dev/null @@ -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 }; -}; diff --git a/packages/signing/index.ts b/packages/signing/index.ts index 48afcbdba..d0cf0e0cd 100644 --- a/packages/signing/index.ts +++ b/packages/signing/index.ts @@ -1,21 +1,59 @@ +import type { Signer } from '@libpdf/core'; +import type { PDF } from '@libpdf/core'; import { match } from 'ts-pattern'; +import { + NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER, + NEXT_PUBLIC_SIGNING_CONTACT_INFO, + NEXT_PUBLIC_WEBAPP_URL, +} from '@documenso/lib/constants/app'; import { env } from '@documenso/lib/utils/env'; -import { signWithGoogleCloudHSM } from './transports/google-cloud-hsm'; -import { signWithLocalCert } from './transports/local-cert'; +import { getTimestampAuthority } from './helpers/tsa'; +import { createGoogleCloudSigner } from './transports/google-cloud'; +import { createLocalSigner } from './transports/local'; export type SignOptions = { - pdf: Buffer; + pdf: PDF; }; -export const signPdf = async ({ pdf }: SignOptions) => { +let signer: Signer | null = null; + +const getSigner = async () => { + if (signer) { + return signer; + } + const transport = env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local'; - return await match(transport) - .with('local', async () => signWithLocalCert({ pdf })) - .with('gcloud-hsm', async () => signWithGoogleCloudHSM({ pdf })) + // eslint-disable-next-line require-atomic-updates + signer = await match(transport) + .with('local', async () => await createLocalSigner()) + .with('gcloud-hsm', async () => await createGoogleCloudSigner()) .otherwise(() => { throw new Error(`Unsupported signing transport: ${transport}`); }); + + return signer; +}; + +export const signPdf = async ({ pdf }: SignOptions) => { + const signer = await getSigner(); + + const tsa = getTimestampAuthority(); + + const { bytes } = await pdf.sign({ + signer, + reason: 'Signed by Documenso', + location: NEXT_PUBLIC_WEBAPP_URL(), + contactInfo: NEXT_PUBLIC_SIGNING_CONTACT_INFO(), + subFilter: NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER() + ? 'adbe.pkcs7.detached' + : 'ETSI.CAdES.detached', + timestampAuthority: tsa ?? undefined, + longTermValidation: !!tsa, + archivalTimestamp: !!tsa, + }); + + return bytes; }; diff --git a/packages/signing/package.json b/packages/signing/package.json index 88862839b..7dac9fdea 100644 --- a/packages/signing/package.json +++ b/packages/signing/package.json @@ -12,11 +12,11 @@ "test": "vitest" }, "dependencies": { - "@documenso/pdf-sign": "^0.1.0", - "@documenso/tsconfig": "*", + "@google-cloud/kms": "^5.2.1", + "@google-cloud/secret-manager": "^6.1.1", "ts-pattern": "^5.9.0" }, "devDependencies": { - "vitest": "^3.2.4" + "@documenso/tsconfig": "*" } } \ No newline at end of file diff --git a/packages/signing/transports/google-cloud-hsm.ts b/packages/signing/transports/google-cloud-hsm.ts deleted file mode 100644 index 1fce63808..000000000 --- a/packages/signing/transports/google-cloud-hsm.ts +++ /dev/null @@ -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; -}; diff --git a/packages/signing/transports/google-cloud.ts b/packages/signing/transports/google-cloud.ts new file mode 100644 index 000000000..00f81a3c1 --- /dev/null +++ b/packages/signing/transports/google-cloud.ts @@ -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 => { + // 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, + }); +}; diff --git a/packages/signing/transports/local-cert.ts b/packages/signing/transports/local-cert.ts deleted file mode 100644 index 5f7890904..000000000 --- a/packages/signing/transports/local-cert.ts +++ /dev/null @@ -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; -}; diff --git a/packages/signing/transports/local.ts b/packages/signing/transports/local.ts new file mode 100644 index 000000000..d5d22c682 --- /dev/null +++ b/packages/signing/transports/local.ts @@ -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, + }); +}; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 65a9ec6d3..5a79b386f 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -41,6 +41,12 @@ declare namespace NodeJS { NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH?: string; NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS?: string; NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS?: string; + NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH?: string; + NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS?: string; + NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH?: string; + NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY?: string; + NEXT_PUBLIC_SIGNING_CONTACT_INFO?: string; + NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER?: string; NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api'; diff --git a/render.yaml b/render.yaml index c55e7e915..902672b0e 100644 --- a/render.yaml +++ b/render.yaml @@ -117,6 +117,18 @@ services: sync: false - key: NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS sync: false + - key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH + sync: false + - key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS + sync: false + - key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH + sync: false + - key: NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY + sync: false + - key: NEXT_PUBLIC_SIGNING_CONTACT_INFO + sync: false + - key: NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER + sync: false # SMTP Optional - key: NEXT_PRIVATE_SMTP_APIKEY_USER diff --git a/turbo.json b/turbo.json index f9e9c78b7..7f54d9862 100644 --- a/turbo.json +++ b/turbo.json @@ -2,20 +2,12 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": [ - "prebuild", - "^build" - ], - "outputs": [ - ".next/**", - "!.next/cache/**" - ] + "dependsOn": ["prebuild", "^build"], + "outputs": [".next/**", "!.next/cache/**"] }, "prebuild": { "cache": false, - "dependsOn": [ - "^prebuild" - ] + "dependsOn": ["^prebuild"] }, "lint": { "cache": false @@ -31,9 +23,7 @@ "persistent": true }, "start": { - "dependsOn": [ - "^build" - ], + "dependsOn": ["^build"], "cache": false, "persistent": true }, @@ -41,15 +31,11 @@ "cache": false }, "test:e2e": { - "dependsOn": [ - "^build" - ], + "dependsOn": ["^build"], "cache": false } }, - "globalDependencies": [ - "**/.env.*local" - ], + "globalDependencies": ["**/.env.*local"], "globalEnv": [ "APP_VERSION", "PORT", @@ -75,6 +61,12 @@ "NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH", "NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS", "NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS", + "NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH", + "NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS", + "NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH", + "NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY", + "NEXT_PUBLIC_SIGNING_CONTACT_INFO", + "NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_SECRET", "NEXT_PRIVATE_OIDC_WELL_KNOWN", @@ -143,4 +135,4 @@ "NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS", "NEXT_PRIVATE_OIDC_PROMPT" ] -} \ No newline at end of file +}