mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat(storage): add native Azure Blob transport (#2871)
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
+226
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Vendored
+5
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user