208 lines
6.5 KiB
TypeScript
208 lines
6.5 KiB
TypeScript
/**
|
|
* Azure Blob Storage provider.
|
|
*
|
|
* Wraps @azure/storage-blob behind the cloud-agnostic StorageProvider interface.
|
|
*/
|
|
|
|
import type {
|
|
BlobMeta,
|
|
SignedUrlOptions,
|
|
StorageBucket,
|
|
StorageProvider,
|
|
UploadOptions,
|
|
} from '../types.js';
|
|
|
|
export interface AzureBlobProviderConfig {
|
|
connectionString?: string;
|
|
accountName?: string;
|
|
accountKey?: string;
|
|
blobEndpoint?: string;
|
|
publicBlobEndpoint?: string;
|
|
}
|
|
|
|
function parseConnectionString(connectionString: string): Partial<AzureBlobProviderConfig> {
|
|
const parts = new Map<string, string>();
|
|
|
|
for (const segment of connectionString.split(';')) {
|
|
const [key, ...rest] = segment.split('=');
|
|
if (!key || rest.length === 0) continue;
|
|
parts.set(key, rest.join('='));
|
|
}
|
|
|
|
return {
|
|
accountName: parts.get('AccountName'),
|
|
accountKey: parts.get('AccountKey'),
|
|
blobEndpoint: parts.get('BlobEndpoint'),
|
|
};
|
|
}
|
|
|
|
export class AzureBlobStorageProvider implements StorageProvider {
|
|
private client: unknown = null;
|
|
private config: AzureBlobProviderConfig;
|
|
private buckets = new Map<string, AzureBlobBucket>();
|
|
|
|
constructor(config?: AzureBlobProviderConfig) {
|
|
const envConfig = config ?? {
|
|
connectionString: process.env.AZURE_BLOB_CONNECTION_STRING,
|
|
accountName: process.env.AZURE_BLOB_ACCOUNT_NAME,
|
|
accountKey: process.env.AZURE_BLOB_ACCOUNT_KEY,
|
|
publicBlobEndpoint: process.env.AZURE_BLOB_PUBLIC_ENDPOINT,
|
|
};
|
|
|
|
const parsed = envConfig.connectionString
|
|
? parseConnectionString(envConfig.connectionString)
|
|
: undefined;
|
|
|
|
this.config = {
|
|
...parsed,
|
|
...envConfig,
|
|
accountName: envConfig.accountName ?? parsed?.accountName,
|
|
accountKey: envConfig.accountKey ?? parsed?.accountKey,
|
|
blobEndpoint: envConfig.blobEndpoint ?? parsed?.blobEndpoint,
|
|
};
|
|
}
|
|
|
|
private async getClient() {
|
|
if (!this.client) {
|
|
const { BlobServiceClient } = await import('@azure/storage-blob');
|
|
if (this.config.connectionString) {
|
|
this.client = BlobServiceClient.fromConnectionString(this.config.connectionString);
|
|
} else if (this.config.accountName && this.config.accountKey) {
|
|
const { StorageSharedKeyCredential } = await import('@azure/storage-blob');
|
|
const cred = new StorageSharedKeyCredential(
|
|
this.config.accountName,
|
|
this.config.accountKey
|
|
);
|
|
const endpoint =
|
|
this.config.blobEndpoint ?? `https://${this.config.accountName}.blob.core.windows.net`;
|
|
this.client = new BlobServiceClient(endpoint, cred);
|
|
} else {
|
|
throw new Error(
|
|
'AzureBlobStorageProvider requires AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY'
|
|
);
|
|
}
|
|
}
|
|
return this.client as import('@azure/storage-blob').BlobServiceClient;
|
|
}
|
|
|
|
getBucket(name: string): StorageBucket {
|
|
let bucket = this.buckets.get(name);
|
|
if (!bucket) {
|
|
bucket = new AzureBlobBucket(name, () => this.getClient(), this.config);
|
|
this.buckets.set(name, bucket);
|
|
}
|
|
return bucket;
|
|
}
|
|
|
|
async isHealthy(): Promise<boolean> {
|
|
try {
|
|
const client = await this.getClient();
|
|
// List one container to verify connectivity
|
|
const iter = client.listContainers();
|
|
await iter.next();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
class AzureBlobBucket implements StorageBucket {
|
|
constructor(
|
|
private containerName: string,
|
|
private getClient: () => Promise<import('@azure/storage-blob').BlobServiceClient>,
|
|
private config: AzureBlobProviderConfig
|
|
) {}
|
|
|
|
private async containerClient() {
|
|
const client = await this.getClient();
|
|
const container = client.getContainerClient(this.containerName);
|
|
await container.createIfNotExists();
|
|
return container;
|
|
}
|
|
|
|
async upload(
|
|
key: string,
|
|
data: Buffer | Uint8Array | string,
|
|
options?: UploadOptions
|
|
): Promise<BlobMeta> {
|
|
const container = await this.containerClient();
|
|
const blockBlob = container.getBlockBlobClient(key);
|
|
const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data);
|
|
await blockBlob.upload(buf, buf.length, {
|
|
blobHTTPHeaders: { blobContentType: options?.contentType },
|
|
metadata: options?.metadata,
|
|
});
|
|
return {
|
|
key,
|
|
size: buf.length,
|
|
contentType: options?.contentType,
|
|
lastModified: new Date(),
|
|
metadata: options?.metadata,
|
|
};
|
|
}
|
|
|
|
async download(key: string): Promise<Buffer> {
|
|
const container = await this.containerClient();
|
|
const blob = container.getBlobClient(key);
|
|
const response = await blob.downloadToBuffer();
|
|
return response;
|
|
}
|
|
|
|
async delete(key: string): Promise<void> {
|
|
const container = await this.containerClient();
|
|
const blob = container.getBlobClient(key);
|
|
await blob.deleteIfExists();
|
|
}
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
const container = await this.containerClient();
|
|
const blob = container.getBlobClient(key);
|
|
return blob.exists();
|
|
}
|
|
|
|
async list(prefix?: string): Promise<BlobMeta[]> {
|
|
const container = await this.containerClient();
|
|
const results: BlobMeta[] = [];
|
|
for await (const blob of container.listBlobsFlat({ prefix: prefix ?? undefined })) {
|
|
results.push({
|
|
key: blob.name,
|
|
size: blob.properties.contentLength ?? undefined,
|
|
contentType: blob.properties.contentType ?? undefined,
|
|
lastModified: blob.properties.lastModified,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async getSignedUrl(key: string, options?: SignedUrlOptions): Promise<string> {
|
|
const { generateBlobSASQueryParameters, BlobSASPermissions, StorageSharedKeyCredential } =
|
|
await import('@azure/storage-blob');
|
|
|
|
if (!this.config.accountName || !this.config.accountKey) {
|
|
throw new Error('Signed URLs require accountName + accountKey');
|
|
}
|
|
|
|
const cred = new StorageSharedKeyCredential(this.config.accountName, this.config.accountKey);
|
|
const expiresOn = new Date(Date.now() + (options?.expiresIn ?? 3600) * 1000);
|
|
const permissions = BlobSASPermissions.parse(options?.permissions === 'write' ? 'w' : 'r');
|
|
|
|
const sas = generateBlobSASQueryParameters(
|
|
{
|
|
containerName: this.containerName,
|
|
blobName: key,
|
|
permissions,
|
|
expiresOn,
|
|
},
|
|
cred
|
|
);
|
|
|
|
const baseUrl =
|
|
this.config.publicBlobEndpoint ??
|
|
this.config.blobEndpoint ??
|
|
`https://${this.config.accountName}.blob.core.windows.net`;
|
|
|
|
return `${baseUrl.replace(/\/$/, '')}/${this.containerName}/${key}?${sas.toString()}`;
|
|
}
|
|
}
|