feat(storage): add Azure Blob Storage driver (#2222)

This commit is contained in:
Philip Okugbe
2026-05-21 12:18:58 +01:00
committed by GitHub
parent ed0501a864
commit 4295ea09f6
9 changed files with 447 additions and 5 deletions
+16 -1
View File
@@ -36,6 +36,7 @@
"@aws-sdk/client-s3": "3.1050.0",
"@aws-sdk/lib-storage": "3.1050.0",
"@aws-sdk/s3-request-presigner": "3.1050.0",
"@azure/storage-blob": "12.31.0",
"@clickhouse/client": "^1.18.2",
"@docmost/pdf-inspector": "1.9.4",
"@fastify/cookie": "^11.0.2",
@@ -163,7 +164,21 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"happy-dom.+\\.js$": ["babel-jest", { "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }],
"happy-dom.+\\.js$": [
"babel-jest",
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
]
}
],
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
@@ -122,6 +122,26 @@ export class EnvironmentService {
return this.configService.get<string>('AWS_S3_URL');
}
getAzureStorageAccountName(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
}
getAzureStorageContainer(): string {
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
}
getAzureStorageAccountKey(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
}
getAzureStorageEndpoint(): string {
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
}
getAzureStorageUrl(): string {
return this.configService.get<string>('AZURE_STORAGE_URL');
}
getMailDriver(): string {
return this.configService.get<string>('MAIL_DRIVER', 'log');
}
@@ -49,7 +49,7 @@ export class EnvironmentVariables {
MAIL_DRIVER: string;
@IsOptional()
@IsIn(['local', 's3'])
@IsIn(['local', 's3', 'azure'])
STORAGE_DRIVER: string;
@IsOptional()
@@ -0,0 +1,192 @@
import { Readable } from 'stream';
import {
AzureStorageConfig,
StorageDriver,
StorageOption,
} from '../interfaces';
import {
BlobSASPermissions,
BlobServiceClient,
BlockBlobClient,
ContainerClient,
generateBlobSASQueryParameters,
SASProtocol,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import { Logger } from '@nestjs/common';
import { getMimeType } from '../../../common/helpers';
export class AzureDriver implements StorageDriver {
private readonly config: AzureStorageConfig;
private readonly blobServiceClient: BlobServiceClient;
private readonly containerClient: ContainerClient;
private readonly sharedKeyCredential: StorageSharedKeyCredential;
private readonly accountUrl: string;
constructor(config: AzureStorageConfig) {
this.config = config;
if (!config.accountName) {
throw new Error('AzureDriver: accountName is required');
}
if (!config.container) {
throw new Error('AzureDriver: container is required');
}
if (!config.accountKey) {
throw new Error('AzureDriver: accountKey is required');
}
this.accountUrl =
config.endpoint ??
`https://${config.accountName}.blob.core.windows.net`;
this.sharedKeyCredential = new StorageSharedKeyCredential(
config.accountName,
config.accountKey,
);
this.blobServiceClient = this.createBlobServiceClient();
this.containerClient = this.blobServiceClient.getContainerClient(
config.container,
);
}
private blockBlob(filePath: string): BlockBlobClient {
return this.containerClient.getBlockBlobClient(filePath);
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
const stream: Readable = Buffer.isBuffer(file) ? Readable.from(file) : file;
await this.uploadStream(filePath, stream);
}
async uploadStream(
filePath: string,
file: Readable,
options?: { recreateClient?: boolean },
): Promise<void> {
const clientToUse = options?.recreateClient
? this.createBlobServiceClient()
.getContainerClient(this.config.container)
.getBlockBlobClient(filePath)
: this.blockBlob(filePath);
try {
const contentType = getMimeType(filePath);
await clientToUse.uploadStream(file, undefined, undefined, {
blobHTTPHeaders: { blobContentType: contentType },
});
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
}
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
try {
if (!(await this.exists(fromFilePath))) {
return;
}
const sourceUrl = await this.getSignedUrl(fromFilePath, 60);
const dest = this.blockBlob(toFilePath);
await dest.syncCopyFromURL(sourceUrl);
} catch (err) {
throw new Error(`Failed to copy file: ${(err as Error).message}`);
}
}
async read(filePath: string): Promise<Buffer> {
try {
return await this.blockBlob(filePath).downloadToBuffer();
} catch (err) {
throw new Error(
`Failed to read file from Azure: ${(err as Error).message}`,
);
}
}
async readStream(filePath: string): Promise<Readable> {
try {
const response = await this.blockBlob(filePath).download();
return response.readableStreamBody as Readable;
} catch (err) {
throw new Error(
`Failed to read file from Azure: ${(err as Error).message}`,
);
}
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
const count = range.end - range.start + 1;
const response = await this.blockBlob(filePath).download(
range.start,
count,
);
return response.readableStreamBody as Readable;
} catch (err) {
throw new Error(
`Failed to read file from Azure: ${(err as Error).message}`,
);
}
}
async exists(filePath: string): Promise<boolean> {
try {
return await this.blockBlob(filePath).exists();
} catch (err) {
throw new Error(
`Failed to check existence in Azure: ${(err as Error).message}`,
);
}
}
getUrl(filePath: string): string {
const base = this.config.baseUrl ?? this.accountUrl;
return `${base}/${this.config.container}/${filePath}`;
}
async getSignedUrl(filePath: string, expiresIn: number): Promise<string> {
const expiresOn = new Date(Date.now() + expiresIn * 1000);
const sas = generateBlobSASQueryParameters(
{
containerName: this.config.container,
blobName: filePath,
permissions: BlobSASPermissions.parse('r'),
expiresOn,
protocol: SASProtocol.HttpsAndHttp,
},
this.sharedKeyCredential,
).toString();
return `${this.accountUrl}/${this.config.container}/${filePath}?${sas}`;
}
async delete(filePath: string): Promise<void> {
try {
await this.blockBlob(filePath).delete();
} catch (err) {
throw new Error(
`Error deleting file ${filePath} from Azure: ${(err as Error).message}`,
);
}
}
getDriver(): BlobServiceClient {
return this.blobServiceClient;
}
getDriverName(): string {
return StorageOption.AZURE;
}
getConfig(): Record<string, any> {
return this.config;
}
private createBlobServiceClient(): BlobServiceClient {
return new BlobServiceClient(this.accountUrl, this.sharedKeyCredential);
}
}
@@ -1,2 +1,3 @@
export { LocalDriver } from './local.driver';
export { S3Driver } from './s3.driver';
export { AzureDriver } from './azure.driver';
@@ -3,11 +3,13 @@ import { S3ClientConfig } from '@aws-sdk/client-s3';
export enum StorageOption {
LOCAL = 'local',
S3 = 's3',
AZURE = 'azure',
}
export type StorageConfig =
| { driver: StorageOption.LOCAL; config: LocalStorageConfig }
| { driver: StorageOption.S3; config: S3StorageConfig };
| { driver: StorageOption.S3; config: S3StorageConfig }
| { driver: StorageOption.AZURE; config: AzureStorageConfig };
export interface LocalStorageConfig {
storagePath: string;
@@ -20,6 +22,14 @@ export interface S3StorageConfig
baseUrl?: string; // Optional CDN URL for assets
}
export interface AzureStorageConfig {
accountName: string;
container: string;
accountKey: string;
endpoint?: string;
baseUrl?: string;
}
export interface StorageOptions {
disk: StorageConfig;
}
@@ -4,13 +4,14 @@ import {
} from '../constants/storage.constants';
import { EnvironmentService } from '../../environment/environment.service';
import {
AzureStorageConfig,
LocalStorageConfig,
S3StorageConfig,
StorageConfig,
StorageDriver,
StorageOption,
} from '../interfaces';
import { LocalDriver, S3Driver } from '../drivers';
import { AzureDriver, LocalDriver, S3Driver } from '../drivers';
import * as process from 'node:process';
import { LOCAL_STORAGE_PATH } from '../../../common/helpers';
import path from 'path';
@@ -21,6 +22,8 @@ function createStorageDriver(disk: StorageConfig): StorageDriver {
return new LocalDriver(disk.config as LocalStorageConfig);
case StorageOption.S3:
return new S3Driver(disk.config as S3StorageConfig);
case StorageOption.AZURE:
return new AzureDriver(disk.config as AzureStorageConfig);
default:
throw new Error(`Unknown storage driver`);
}
@@ -70,6 +73,18 @@ export const storageDriverConfigProvider = {
return s3Config; }
case StorageOption.AZURE:
return {
driver,
config: {
accountName: environmentService.getAzureStorageAccountName(),
container: environmentService.getAzureStorageContainer(),
accountKey: environmentService.getAzureStorageAccountKey(),
endpoint: environmentService.getAzureStorageEndpoint() || undefined,
baseUrl: environmentService.getAzureStorageUrl() || undefined,
},
};
default:
throw new Error(`Unknown storage driver: ${driver}`);
}