feat(storage): add native Azure Blob transport (#2871)

This commit is contained in:
Kendry Grullon
2026-05-27 00:58:39 -04:00
committed by GitHub
parent 993df7dc21
commit 9da2db2e67
11 changed files with 662 additions and 150 deletions
@@ -1,6 +1,6 @@
---
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';
@@ -10,10 +10,11 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Storage Options
| Backend | Best For | Scalability | Configuration |
| ---------- | -------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required |
| Backend | Best For | Scalability | Configuration |
| ------------ | --------------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None 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:
@@ -23,6 +24,9 @@ NEXT_PUBLIC_UPLOAD_TRANSPORT=database
# S3-compatible storage
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)
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_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": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -14080,6 +14282,20 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -18537,6 +18753,15 @@
"dev": true,
"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": {
"version": "3.0.6",
"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/s3-request-presigner": "^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/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6",
+1
View File
@@ -21,6 +21,7 @@
"@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^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/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6",
@@ -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');
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));
};
@@ -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 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');
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));
};
@@ -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`, {
method: 'POST',
headers: {
@@ -82,16 +83,17 @@ const putFileInS3 = async (file: File) => {
const body = await file.arrayBuffer();
const reponse = await fetch(url, {
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!reponse.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${reponse.status}`);
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
+14 -137
View File
@@ -1,154 +1,31 @@
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 { getStorageProvider } from './providers';
export const getPresignPostUrl = async (fileName: string, contentType: string, userId?: number) => {
const client = getS3Client();
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 };
return getStorageProvider().getPresignPostUrl(fileName, contentType, userId);
};
export const getAbsolutePresignPostUrl = async (key: string) => {
const client = getS3Client();
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 };
return getStorageProvider().getAbsolutePresignPostUrl(key);
};
export const getPresignGetUrl = async (key: string) => {
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 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 };
return getStorageProvider().getPresignGetUrl(key);
};
/**
* 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) => {
const client = getS3Client();
// Get the basename and extension for the file
const { name, ext } = path.parse(file.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
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 };
const buffer = await file.arrayBuffer();
const { key } = await getStorageProvider().uploadFile({
name: file.name,
type: file.type,
body: buffer,
});
return { key };
};
export const deleteS3File = async (key: string) => {
const client = getS3Client();
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,
});
return getStorageProvider().deleteFile(key);
};
+5 -1
View File
@@ -22,7 +22,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: 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_FORCE_PATH_STYLE?: string;
NEXT_PRIVATE_UPLOAD_REGION?: string;
@@ -32,6 +32,10 @@ declare namespace NodeJS {
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN?: string;
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID?: 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_PASSPHRASE?: string;