diff --git a/apps/docs/content/docs/self-hosting/configuration/storage.mdx b/apps/docs/content/docs/self-hosting/configuration/storage.mdx index 3ddd5eac8..590ff9629 100644 --- a/apps/docs/content/docs/self-hosting/configuration/storage.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/storage.mdx @@ -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://.blob.core.windows.net` | + +### Azure Setup + +{/* prettier-ignore */} + + +### 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 +``` + + + +### 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 +``` + + + +### 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 +``` + + + + +### 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 +``` + + + The Azurite key shown above is the public well-known development key, published by Microsoft for emulator use. Never reuse it in production. + + +--- + ## CloudFront CDN (Optional) Use Amazon CloudFront to serve documents with lower latency and reduced S3 costs. CloudFront integration uses signed URLs for secure access. diff --git a/package-lock.json b/package-lock.json index 5e21b098a..935b1011e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/lib/package.json b/packages/lib/package.json index 76ee629d5..8f33ba24d 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -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", diff --git a/packages/lib/universal/upload/providers/azure-blob-provider.ts b/packages/lib/universal/upload/providers/azure-blob-provider.ts new file mode 100644 index 000000000..1da2548f5 --- /dev/null +++ b/packages/lib/universal/upload/providers/azure-blob-provider.ts @@ -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 { + 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 { + const url = this.buildSasUrl(key, BlobSASPermissions.parse('cw')); + return { key, url }; + } + + async getPresignGetUrl(key: string): Promise { + const url = this.buildSasUrl(key, BlobSASPermissions.parse('r')); + return { key, url }; + } + + async uploadFile(input: UploadFileInput): Promise { + 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 { + const containerClient = this.serviceClient.getContainerClient(this.containerName); + await containerClient.deleteBlob(key); + } +} diff --git a/packages/lib/universal/upload/providers/index.ts b/packages/lib/universal/upload/providers/index.ts new file mode 100644 index 000000000..071e4cf28 --- /dev/null +++ b/packages/lib/universal/upload/providers/index.ts @@ -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".`); + } +}; diff --git a/packages/lib/universal/upload/providers/s3-provider.ts b/packages/lib/universal/upload/providers/s3-provider.ts new file mode 100644 index 000000000..2aa73cf9f --- /dev/null +++ b/packages/lib/universal/upload/providers/s3-provider.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await this.client.send( + new DeleteObjectCommand({ + Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'), + Key: key, + }), + ); + } +} diff --git a/packages/lib/universal/upload/providers/storage-provider.ts b/packages/lib/universal/upload/providers/storage-provider.ts new file mode 100644 index 000000000..2a4533390 --- /dev/null +++ b/packages/lib/universal/upload/providers/storage-provider.ts @@ -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; + + /** + * 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; + + /** + * Generate a presigned URL to download a file by key. + */ + getPresignGetUrl(key: string): Promise; + + /** + * Server-side upload of a file's bytes. Returns the chosen key. + */ + uploadFile(input: UploadFileInput): Promise; + + /** + * Server-side delete of a file by key. + */ + deleteFile(key: string): Promise; +} diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index c87ad5bec..07064c28a 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -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 }); diff --git a/packages/lib/universal/upload/put-file.ts b/packages/lib/universal/upload/put-file.ts index fbbb9a6af..b0d346eda 100644 --- a/packages/lib/universal/upload/put-file.ts +++ b/packages/lib/universal/upload/put-file.ts @@ -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) => { 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 { diff --git a/packages/lib/universal/upload/server-actions.ts b/packages/lib/universal/upload/server-actions.ts index 72fb8b0bd..62202d38e 100644 --- a/packages/lib/universal/upload/server-actions.ts +++ b/packages/lib/universal/upload/server-actions.ts @@ -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); }; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index bb29fb271..31878ed6d 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -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;