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
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;