Merge branch 'main' into feature/pdf-placeholder-selection-fields

This commit is contained in:
Catalin Pit
2026-05-27 12:50:34 +03:00
committed by GitHub
16 changed files with 800 additions and 170 deletions
@@ -97,12 +97,12 @@ Documenso implements digital signatures with the following characteristics:
- **Timestamps**: RFC 3161 timestamps can be applied to signatures - **Timestamps**: RFC 3161 timestamps can be applied to signatures
- **Signature visualization**: Signed documents include visual signature representations - **Signature visualization**: Signed documents include visual signature representations
For specific implementation details and configuration options, refer to the [signing certificates](/signing-certificates/overview) documentation. For specific implementation details and configuration options, refer to the [signing certificates](/docs/concepts/signing-certificates) documentation.
Self-hosted deployments can configure their own signing certificates and timestamp authorities to meet specific compliance requirements. Self-hosted deployments can configure their own signing certificates and timestamp authorities to meet specific compliance requirements.
## Related ## Related
- [Legal Validity](/compliance/legal-validity) - Legal frameworks for electronic signatures - [E-Sign Compliance](/docs/compliance/esign) - Legal frameworks for electronic signatures
- [Signing Certificates Overview](/signing-certificates/overview) - Certificate configuration - [Signing Certificates](/docs/concepts/signing-certificates) - Certificate configuration
- [Audit Log](/features/audit-log) - Document activity tracking - [Signing Workflow](/docs/concepts/signing-workflow) - Document activity and audit trail
@@ -167,5 +167,5 @@ To enable sequential signing:
## Related ## Related
- [Add Recipients](/users/documents/add-recipients) - How to add recipients to a document - [Add Recipients](/docs/users/documents/add-recipients) - How to add recipients to a document
- [Field Types](/concepts/field-types) - Learn about the different field types you can assign to recipients - [Field Types](/docs/concepts/field-types) - Learn about the different field types you can assign to recipients
@@ -1,6 +1,6 @@
--- ---
title: Storage Configuration title: Storage Configuration
description: Configure file storage for uploaded documents and signed PDFs using database storage (default) or S3-compatible object storage. description: Configure file storage for uploaded documents and signed PDFs using database storage (default), S3-compatible object storage, or Azure Blob Storage.
--- ---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
@@ -10,10 +10,11 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Storage Options ## Storage Options
| Backend | Best For | Scalability | Configuration | | Backend | Best For | Scalability | Configuration |
| ---------- | -------------------------------- | ----------- | ------------- | | ------------ | --------------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required | | `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required | | `s3` | Production, large files, backups | High | Required |
| `azure-blob` | Production on Azure, native Blob access | High | Required |
Select the storage backend with the `NEXT_PUBLIC_UPLOAD_TRANSPORT` environment variable: Select the storage backend with the `NEXT_PUBLIC_UPLOAD_TRANSPORT` environment variable:
@@ -23,6 +24,9 @@ NEXT_PUBLIC_UPLOAD_TRANSPORT=database
# S3-compatible storage # S3-compatible storage
NEXT_PUBLIC_UPLOAD_TRANSPORT=s3 NEXT_PUBLIC_UPLOAD_TRANSPORT=s3
# Azure Blob Storage (native)
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
``` ```
--- ---
@@ -283,6 +287,111 @@ NEXT_PRIVATE_UPLOAD_REGION=us-east-1
--- ---
## Azure Blob Storage
Azure Blob Storage is supported as a native transport (not S3-compatible). Documenso uses the official `@azure/storage-blob` SDK and signs SAS URLs with the Storage Account key for browser uploads and downloads.
### Required Variables
| Variable | Description |
| --------------------------------------- | ------------------------------------------------- |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Set to `azure-blob` |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME` | Azure Storage Account name |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY` | Azure Storage Account access key |
| `NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER` | Container name where uploads are stored |
### Optional Variables
| Variable | Description | Default |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT` | Custom Blob endpoint URL. Useful for local development against Azurite (for example `http://127.0.0.1:10000`). | `https://<account>.blob.core.windows.net` |
### Azure Setup
{/* prettier-ignore */}
<Steps>
<Step>
### Create a Storage Account and Container
Create a Storage Account in the Azure Portal or via the Azure CLI, then create a container inside it:
```bash
az storage account create \
--name yourstorageaccount \
--resource-group your-rg \
--location eastus \
--sku Standard_LRS
az storage container create \
--name documenso-documents \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure CORS on the container
The browser uploads documents directly to Azure Blob using a SAS URL, and downloads them the same way, so the Storage Account needs CORS rules that allow your application origin:
```bash
az storage cors add \
--services b \
--methods GET PUT \
--origins https://your-documenso-domain.com \
--allowed-headers "Content-Type" "x-ms-blob-type" "Authorization" \
--exposed-headers "*" \
--max-age 3600 \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure Environment Variables
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=yourstorageaccount
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=your-account-key
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
```
</Step>
</Steps>
### Local Development with Azurite
Azurite is the official Azure Storage emulator. It supports the Blob REST API with account-key authentication.
```bash
docker run -d --name azurite \
-p 10000:10000 -p 10001:10001 -p 10002:10002 \
mcr.microsoft.com/azure-storage/azurite
```
Create the container against the well-known development account:
```bash
az storage container create \
--name documenso-documents \
--connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
```
Configure environment variables to point at the emulator:
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=devstoreaccount1
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT=http://127.0.0.1:10000
```
<Callout type="info">
The Azurite key shown above is the public well-known development key, published by Microsoft for emulator use. Never reuse it in production.
</Callout>
---
## CloudFront CDN (Optional) ## CloudFront CDN (Optional)
Use Amazon CloudFront to serve documents with lower latency and reduced S3 costs. CloudFront integration uses signed URLs for secure access. Use Amazon CloudFront to serve documents with lower latency and reduced S3 costs. CloudFront integration uses signed URLs for secure access.
+226
View File
@@ -1678,6 +1678,208 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-auth": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz",
"integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-util": "^1.13.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-client": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
"@azure/core-rest-pipeline": "^1.22.0",
"@azure/core-tracing": "^1.3.0",
"@azure/core-util": "^1.13.0",
"@azure/logger": "^1.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-http-compat": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz",
"integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@azure/core-client": "^1.10.0",
"@azure/core-rest-pipeline": "^1.22.0"
}
},
"node_modules/@azure/core-lro": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz",
"integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-util": "^1.2.0",
"@azure/logger": "^1.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-paging": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
"integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-rest-pipeline": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz",
"integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
"@azure/core-tracing": "^1.3.0",
"@azure/core-util": "^1.13.0",
"@azure/logger": "^1.3.0",
"@typespec/ts-http-runtime": "^0.3.4",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-tracing": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
"integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-util": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz",
"integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-xml": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz",
"integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.5.9",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/logger": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
"integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==",
"license": "MIT",
"dependencies": {
"@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/storage-blob": {
"version": "12.31.0",
"resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.31.0.tgz",
"integrity": "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.9.0",
"@azure/core-client": "^1.9.3",
"@azure/core-http-compat": "^2.2.0",
"@azure/core-lro": "^2.2.0",
"@azure/core-paging": "^1.6.2",
"@azure/core-rest-pipeline": "^1.19.1",
"@azure/core-tracing": "^1.2.0",
"@azure/core-util": "^1.11.0",
"@azure/core-xml": "^1.4.5",
"@azure/logger": "^1.1.4",
"@azure/storage-common": "^12.3.0",
"events": "^3.0.0",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/storage-common": {
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.3.0.tgz",
"integrity": "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.9.0",
"@azure/core-http-compat": "^2.2.0",
"@azure/core-rest-pipeline": "^1.19.1",
"@azure/core-tracing": "^1.2.0",
"@azure/core-util": "^1.11.0",
"@azure/logger": "^1.1.4",
"events": "^3.3.0",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -14080,6 +14282,20 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz",
"integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==",
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -18537,6 +18753,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/eventsource-parser": { "node_modules/eventsource-parser": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
@@ -30684,6 +30909,7 @@
"@aws-sdk/cloudfront-signer": "^3.998.0", "@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^3.998.0", "@aws-sdk/s3-request-presigner": "^3.998.0",
"@aws-sdk/signature-v4-crt": "^3.998.0", "@aws-sdk/signature-v4-crt": "^3.998.0",
"@azure/storage-blob": "^12.31.0",
"@bull-board/api": "^6.20.6", "@bull-board/api": "^6.20.6",
"@bull-board/hono": "^6.20.6", "@bull-board/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6", "@bull-board/ui": "^6.20.6",
@@ -1,4 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
@@ -7,10 +8,11 @@ import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users'; import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { apiSignin } from '../fixtures/authentication'; import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
// Duped from `packages/lib/utils/teams.ts` due to errors when importing that file. // Duped from `packages/lib/utils/teams.ts` due to errors when importing that file.
const formatDocumentsPath = (teamUrl: string) => `/t/${teamUrl}/documents`; const formatDocumentsPath = (teamUrl: string) => `/t/${teamUrl}/documents`;
@@ -18,6 +20,47 @@ const formatTemplatesPath = (teamUrl: string) => `/t/${teamUrl}/templates`;
const nanoid = customAlphabet('1234567890abcdef', 10); const nanoid = customAlphabet('1234567890abcdef', 10);
const expectSigningRequestJobForRecipient = async (recipientId: number) => {
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'send.signing.requested.email',
payload: {
path: ['recipientId'],
equals: recipientId,
},
},
});
expect(job).not.toBeNull();
};
const seedSignatureFieldForRecipient = async (options: {
envelopeId: string;
recipientId: number;
positionY: number;
}) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: options.envelopeId },
});
return await prisma.field.create({
data: {
envelopeId: options.envelopeId,
envelopeItemId: envelopeItem.id,
recipientId: options.recipientId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: options.positionY,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
};
test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => { test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({ const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1, createTeamMembers: 1,
@@ -256,11 +299,24 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
}, },
}); });
const directTemplateRecipient = template.recipients[0];
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2'; const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail(); const originalSecondSignerEmail = seedTestEmail();
// Add another signer // Add another signer
await prisma.recipient.create({ const secondRecipient = await prisma.recipient.create({
data: { data: {
signingOrder: 2, signingOrder: 2,
envelopeId: template.id, envelopeId: template.id,
@@ -271,6 +327,12 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
}, },
}); });
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: secondRecipient.id,
positionY: 20,
});
// Check that the direct template link is accessible. // Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@@ -279,6 +341,12 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Sign the direct template recipient's signature field via the UI.
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByText('Next Recipient Name')).toBeVisible(); await expect(page.getByText('Next Recipient Name')).toBeVisible();
@@ -309,8 +377,15 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2); const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
expect(updatedSecondRecipient?.name).toBe(newName); expect(updatedSecondRecipient).toBeDefined();
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
if (!updatedSecondRecipient) {
throw new Error('Expected second recipient to exist');
}
expect(updatedSecondRecipient.name).toBe(newName);
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
}); });
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
@@ -338,11 +413,24 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
}, },
}); });
const directTemplateRecipient = template.recipients[0];
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2'; const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail(); const originalSecondSignerEmail = seedTestEmail();
// Add another signer // Add another signer
await prisma.recipient.create({ const secondRecipient = await prisma.recipient.create({
data: { data: {
signingOrder: 2, signingOrder: 2,
envelopeId: template.id, envelopeId: template.id,
@@ -353,10 +441,39 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
}, },
}); });
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: secondRecipient.id,
positionY: 20,
});
// Check that the direct template link is accessible. // Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
await page.waitForTimeout(100);
// Wait for the PDF and the Konva canvas overlay to be ready.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
@@ -394,6 +511,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2); const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
expect(updatedSecondRecipient?.name).toBe(newName); expect(updatedSecondRecipient).toBeDefined();
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
if (!updatedSecondRecipient) {
throw new Error('Expected second recipient to exist');
}
expect(updatedSecondRecipient.name).toBe(newName);
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
}); });
+1
View File
@@ -21,6 +21,7 @@
"@aws-sdk/cloudfront-signer": "^3.998.0", "@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^3.998.0", "@aws-sdk/s3-request-presigner": "^3.998.0",
"@aws-sdk/signature-v4-crt": "^3.998.0", "@aws-sdk/signature-v4-crt": "^3.998.0",
"@azure/storage-blob": "^12.31.0",
"@bull-board/api": "^6.20.6", "@bull-board/api": "^6.20.6",
"@bull-board/hono": "^6.20.6", "@bull-board/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6", "@bull-board/ui": "^6.20.6",
@@ -104,10 +104,6 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
recipientsToNotify = envelope.recipients recipientsToNotify = envelope.recipients
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC) .filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
.slice(0, 1); .slice(0, 1);
// Secondary filter so we aren't resending if the current active recipient has already
// received the envelope.
recipientsToNotify.filter((r) => r.sendStatus !== SendStatus.SENT);
} }
if (envelope.envelopeItems.length === 0) { if (envelope.envelopeItems.length === 0) {
@@ -669,8 +669,6 @@ export const createDocumentFromDirectTemplate = async ({
await tx.recipient.update({ await tx.recipient.update({
where: { id: nextRecipient.id }, where: { id: nextRecipient.id },
data: { data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner ...(nextSigner && documentMeta?.allowDictateNextSigner
? { ? {
name: nextSigner.name, name: nextSigner.name,
@@ -0,0 +1,100 @@
import path from 'node:path';
import {
BlobSASPermissions,
BlobServiceClient,
generateBlobSASQueryParameters,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import { env } from '@documenso/lib/utils/env';
import slugify from '@sindresorhus/slugify';
import { ONE_HOUR } from '../../../constants/time';
import { alphaid } from '../../id';
import type { PresignedUrl, StorageProvider, UploadFileInput, UploadFileResult } from './storage-provider';
export class AzureBlobProvider implements StorageProvider {
private serviceClient: BlobServiceClient;
private credential: StorageSharedKeyCredential;
private containerName: string;
constructor() {
const accountName = String(env('NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME'));
const accountKey = String(env('NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY'));
this.containerName = String(env('NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER'));
this.credential = new StorageSharedKeyCredential(accountName, accountKey);
const endpointOverride = env('NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT');
const url = endpointOverride
? `${endpointOverride}/${accountName}`
: `https://${accountName}.blob.core.windows.net`;
this.serviceClient = new BlobServiceClient(url, this.credential);
}
private buildSasUrl(key: string, permissions: BlobSASPermissions): string {
const expiresOn = new Date(Date.now() + ONE_HOUR);
const sasToken = generateBlobSASQueryParameters(
{
containerName: this.containerName,
blobName: key,
permissions,
expiresOn,
},
this.credential,
).toString();
const blobClient = this.serviceClient.getContainerClient(this.containerName).getBlobClient(key);
return `${blobClient.url}?${sasToken}`;
}
async getPresignPostUrl(fileName: string, _contentType: string, userId?: number): Promise<PresignedUrl> {
const { name, ext } = path.parse(fileName);
let slugified = slugify(name);
if (slugified.length === 0 || slugified.length > 100) {
slugified = alphaid(8);
}
let key = `${alphaid(12)}/${slugified}${ext}`;
if (userId) {
key = `${userId}/${key}`;
}
const url = this.buildSasUrl(key, BlobSASPermissions.parse('cw'));
return { key, url };
}
async getAbsolutePresignPostUrl(key: string): Promise<PresignedUrl> {
const url = this.buildSasUrl(key, BlobSASPermissions.parse('cw'));
return { key, url };
}
async getPresignGetUrl(key: string): Promise<PresignedUrl> {
const url = this.buildSasUrl(key, BlobSASPermissions.parse('r'));
return { key, url };
}
async uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const { name, ext } = path.parse(input.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
const containerClient = this.serviceClient.getContainerClient(this.containerName);
const blockBlobClient = containerClient.getBlockBlobClient(key);
const body = input.body instanceof ArrayBuffer ? Buffer.from(input.body) : input.body;
await blockBlobClient.uploadData(body, {
blobHTTPHeaders: { blobContentType: input.type },
});
return { key };
}
async deleteFile(key: string): Promise<void> {
const containerClient = this.serviceClient.getContainerClient(this.containerName);
await containerClient.deleteBlob(key);
}
}
@@ -0,0 +1,28 @@
import { env } from '@documenso/lib/utils/env';
import { AzureBlobProvider } from './azure-blob-provider';
import { S3Provider } from './s3-provider';
import type { StorageProvider } from './storage-provider';
export type { PresignedUrl, StorageProvider, UploadFileInput, UploadFileResult } from './storage-provider';
let cached: StorageProvider | null = null;
export const getStorageProvider = (): StorageProvider => {
if (cached) {
return cached;
}
const transport = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
switch (transport) {
case 's3':
cached = new S3Provider();
return cached;
case 'azure-blob':
cached = new AzureBlobProvider();
return cached;
default:
throw new Error(`Invalid object storage transport: "${transport}". Expected "s3" or "azure-blob".`);
}
};
@@ -0,0 +1,120 @@
import path from 'node:path';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { env } from '@documenso/lib/utils/env';
import slugify from '@sindresorhus/slugify';
import { ONE_HOUR, ONE_SECOND } from '../../../constants/time';
import { alphaid } from '../../id';
import type { PresignedUrl, StorageProvider, UploadFileInput, UploadFileResult } from './storage-provider';
export class S3Provider implements StorageProvider {
private client: S3Client;
constructor() {
const hasCredentials = env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID') && env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY');
this.client = new S3Client({
endpoint: env('NEXT_PRIVATE_UPLOAD_ENDPOINT') || undefined,
forcePathStyle: env('NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE') === 'true',
region: env('NEXT_PRIVATE_UPLOAD_REGION') || 'us-east-1',
credentials: hasCredentials
? {
accessKeyId: String(env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID')),
secretAccessKey: String(env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY')),
}
: undefined,
});
}
async getPresignPostUrl(fileName: string, contentType: string, userId?: number): Promise<PresignedUrl> {
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const { name, ext } = path.parse(fileName);
let slugified = slugify(name);
if (slugified.length === 0 || slugified.length > 100) {
slugified = alphaid(8);
}
let key = `${alphaid(12)}/${slugified}${ext}`;
if (userId) {
key = `${userId}/${key}`;
}
const command = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(this.client, command, { expiresIn: ONE_HOUR / ONE_SECOND });
return { key, url };
}
async getAbsolutePresignPostUrl(key: string): Promise<PresignedUrl> {
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const command = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getSignedUrl(this.client, command, { expiresIn: ONE_HOUR / ONE_SECOND });
return { key, url };
}
async getPresignGetUrl(key: string): Promise<PresignedUrl> {
if (env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')) {
const distributionUrl = new URL(key, `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')}`);
const { getSignedUrl: getCloudfrontSignedUrl } = await import('@aws-sdk/cloudfront-signer');
const url = getCloudfrontSignedUrl({
url: distributionUrl.toString(),
keyPairId: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID')}`,
privateKey: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS')}`,
dateLessThan: new Date(Date.now() + ONE_HOUR).toISOString(),
});
return { key, url };
}
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const command = new GetObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getSignedUrl(this.client, command, { expiresIn: ONE_HOUR / ONE_SECOND });
return { key, url };
}
async uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const { name, ext } = path.parse(input.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
const body = input.body instanceof ArrayBuffer ? Buffer.from(input.body) : input.body;
await this.client.send(
new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
Body: body,
ContentType: input.type,
}),
);
return { key };
}
async deleteFile(key: string): Promise<void> {
await this.client.send(
new DeleteObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
}),
);
}
}
@@ -0,0 +1,44 @@
export type PresignedUrl = {
key: string;
url: string;
};
export type UploadFileInput = {
name: string;
type: string;
body: ArrayBuffer | Buffer;
};
export type UploadFileResult = {
key: string;
};
export interface StorageProvider {
/**
* Generate a presigned URL to upload a file by name. The provider chooses the
* final object key (typically derived from a slugified file name plus a
* random prefix) and returns it along with the signed URL.
*/
getPresignPostUrl(fileName: string, contentType: string, userId?: number): Promise<PresignedUrl>;
/**
* Generate a presigned URL to upload to an already-known key (used for flows
* where the destination has been chosen previously).
*/
getAbsolutePresignPostUrl(key: string): Promise<PresignedUrl>;
/**
* Generate a presigned URL to download a file by key.
*/
getPresignGetUrl(key: string): Promise<PresignedUrl>;
/**
* Server-side upload of a file's bytes. Returns the chosen key.
*/
uploadFile(input: UploadFileInput): Promise<UploadFileResult>;
/**
* Server-side delete of a file by key.
*/
deleteFile(key: string): Promise<void>;
}
@@ -77,7 +77,8 @@ export const putFileServerSide = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file)) .with('s3', async () => putFileInObjectStorage(file))
.with('azure-blob', async () => putFileInObjectStorage(file))
.otherwise(async () => putFileInDatabase(file)); .otherwise(async () => putFileInDatabase(file));
}; };
@@ -94,7 +95,7 @@ const putFileInDatabase = async (file: File) => {
}; };
}; };
const putFileInS3 = async (file: File) => { const putFileInObjectStorage = async (file: File) => {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const blob = new Blob([buffer], { type: file.type }); const blob = new Blob([buffer], { type: file.type });
+7 -5
View File
@@ -45,7 +45,8 @@ export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file)) .with('s3', async () => putFileInObjectStorage(file, {}))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }))
.otherwise(async () => putFileInDatabase(file)); .otherwise(async () => putFileInDatabase(file));
}; };
@@ -62,7 +63,7 @@ const putFileInDatabase = async (file: File) => {
}; };
}; };
const putFileInS3 = async (file: File) => { const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, { const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -82,16 +83,17 @@ const putFileInS3 = async (file: File) => {
const body = await file.arrayBuffer(); const body = await file.arrayBuffer();
const reponse = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
...extraHeaders,
}, },
body, body,
}); });
if (!reponse.ok) { if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${reponse.status}`); throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
} }
return { return {
+14 -137
View File
@@ -1,154 +1,31 @@
import path from 'node:path'; import { getStorageProvider } from './providers';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { env } from '@documenso/lib/utils/env';
import slugify from '@sindresorhus/slugify';
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string, userId?: number) => { export const getPresignPostUrl = async (fileName: string, contentType: string, userId?: number) => {
const client = getS3Client(); return getStorageProvider().getPresignPostUrl(fileName, contentType, userId);
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
let slugified = slugify(name);
// If the slugified name is empty or too long, generate a random string instead
//
// This is fine since we don't really need the filename in s3 since we store it
// in the database and can always get the original filename from there.
//
// The slugified name can be empty when a string contains only CJK or other
// special characters.
if (slugified.length === 0 || slugified.length > 100) {
slugified = alphaid(8);
}
let key = `${alphaid(12)}/${slugified}${ext}`;
if (userId) {
key = `${userId}/${key}`;
}
const putObjectCommand = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
}; };
export const getAbsolutePresignPostUrl = async (key: string) => { export const getAbsolutePresignPostUrl = async (key: string) => {
const client = getS3Client(); return getStorageProvider().getAbsolutePresignPostUrl(key);
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
const putObjectCommand = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getS3SignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
}; };
export const getPresignGetUrl = async (key: string) => { export const getPresignGetUrl = async (key: string) => {
if (env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')) { return getStorageProvider().getPresignGetUrl(key);
const distributionUrl = new URL(key, `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')}`);
const { getSignedUrl: getCloudfrontSignedUrl } = await import('@aws-sdk/cloudfront-signer');
const url = getCloudfrontSignedUrl({
url: distributionUrl.toString(),
keyPairId: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID')}`,
privateKey: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS')}`,
dateLessThan: new Date(Date.now() + ONE_HOUR).toISOString(),
});
return { key, url };
}
const client = getS3Client();
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
const getObjectCommand = new GetObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getS3SignedUrl(client, getObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
}; };
/** /**
* Uploads a file to S3. * Uploads a file server-side. Name preserved for backward compatibility with
* existing callers; underneath it delegates to the active storage provider.
*/ */
export const uploadS3File = async (file: File) => { export const uploadS3File = async (file: File) => {
const client = getS3Client(); const buffer = await file.arrayBuffer();
const { key } = await getStorageProvider().uploadFile({
// Get the basename and extension for the file name: file.name,
const { name, ext } = path.parse(file.name); type: file.type,
body: buffer,
const key = `${alphaid(12)}/${slugify(name)}${ext}`; });
return { key };
const fileBuffer = await file.arrayBuffer();
const response = await client.send(
new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
Body: Buffer.from(fileBuffer),
ContentType: file.type,
}),
);
return { key, response };
}; };
export const deleteS3File = async (key: string) => { export const deleteS3File = async (key: string) => {
const client = getS3Client(); return getStorageProvider().deleteFile(key);
await client.send(
new DeleteObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
}),
);
};
const getS3Client = () => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new Error('Invalid upload transport');
}
const hasCredentials = env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID') && env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY');
return new S3Client({
endpoint: env('NEXT_PRIVATE_UPLOAD_ENDPOINT') || undefined,
forcePathStyle: env('NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE') === 'true',
region: env('NEXT_PRIVATE_UPLOAD_REGION') || 'us-east-1',
credentials: hasCredentials
? {
accessKeyId: String(env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID')),
secretAccessKey: String(env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY')),
}
: undefined,
});
}; };
+5 -1
View File
@@ -22,7 +22,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_UPLOAD_TRANSPORT?: 'database' | 's3'; NEXT_PUBLIC_UPLOAD_TRANSPORT?: 'database' | 's3' | 'azure-blob';
NEXT_PRIVATE_UPLOAD_ENDPOINT?: string; NEXT_PRIVATE_UPLOAD_ENDPOINT?: string;
NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE?: string; NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE?: string;
NEXT_PRIVATE_UPLOAD_REGION?: string; NEXT_PRIVATE_UPLOAD_REGION?: string;
@@ -32,6 +32,10 @@ declare namespace NodeJS {
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN?: string; NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN?: string;
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID?: string; NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID?: string;
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS?: string; NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS?: string;
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME?: string;
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY?: string;
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER?: string;
NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT?: string;
NEXT_PRIVATE_SIGNING_TRANSPORT?: 'local' | 'http' | 'gcloud-hsm'; NEXT_PRIVATE_SIGNING_TRANSPORT?: 'local' | 'http' | 'gcloud-hsm';
NEXT_PRIVATE_SIGNING_PASSPHRASE?: string; NEXT_PRIVATE_SIGNING_PASSPHRASE?: string;