/** * Shared Azure Blob Storage utilities. * * Provides singleton BlobServiceClient, container helpers, and SAS token generation. * Containers are lazily created on first access (createIfNotExists). * * Expected env vars: * AZURE_BLOB_CONNECTION_STRING — full connection string (preferred) * — OR — * AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY */ import { BlobServiceClient, ContainerClient, StorageSharedKeyCredential, BlobSASPermissions, generateBlobSASQueryParameters, SASProtocol, } from '@azure/storage-blob'; let serviceClient: BlobServiceClient | null = null; const containerClients = new Map(); /** * Known blob containers and their purposes. * * Note: This is a convenience list (not enforced). Products can add their own * containers as needed. */ export const BLOB_CONTAINERS = { audio: 'audio', // Dictation audio recordings transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT) attachments: 'attachments', // Tracker item attachments (screenshots, docs) avatars: 'avatars', // User profile images releases: 'releases', // Desktop app update binaries backups: 'backups', // Cosmos DB JSON backups } as const; export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS]; /** * Get or create the BlobServiceClient singleton. */ export function getBlobServiceClient(): BlobServiceClient { if (serviceClient) return serviceClient; const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING; if (connectionString) { serviceClient = BlobServiceClient.fromConnectionString(connectionString); return serviceClient; } const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME; const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY; if (accountName && accountKey) { const credential = new StorageSharedKeyCredential(accountName, accountKey); serviceClient = new BlobServiceClient( `https://${accountName}.blob.core.windows.net`, credential ); return serviceClient; } throw new Error( 'Azure Blob Storage not configured. Set AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY' ); } /** * Get a container client, creating the container if it doesn't exist. */ export async function getContainerClient(containerName: string): Promise { const cached = containerClients.get(containerName); if (cached) return cached; const client = getBlobServiceClient().getContainerClient(containerName); await client.createIfNotExists({ access: undefined }); // private by default containerClients.set(containerName, client); return client; } /** * Generate a SAS URL for direct browser upload (or download). * * @param containerName - Target container * @param blobName - Full blob path (e.g., "product/user123/audio/recording.wav") * @param permissions - SAS permissions (default: read) * @param expiresInMinutes - Token lifetime (default: 60) * @returns Full SAS URL for the blob */ export function generateSasUrl( containerName: string, blobName: string, permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r', expiresInMinutes = 60 ): string { const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING; const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME; const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY; let credAccountName: string; let credential: StorageSharedKeyCredential; if (accountName && accountKey) { credAccountName = accountName; credential = new StorageSharedKeyCredential(accountName, accountKey); } else if (connectionString) { // Parse account name and key from connection string const nameMatch = connectionString.match(/AccountName=([^;]+)/); const keyMatch = connectionString.match(/AccountKey=([^;]+)/); if (!nameMatch || !keyMatch) { throw new Error('Cannot parse AccountName/AccountKey from connection string'); } credAccountName = nameMatch[1]; credential = new StorageSharedKeyCredential(nameMatch[1], keyMatch[1]); } else { throw new Error('Blob storage credentials not configured'); } const sasPermissions = new BlobSASPermissions(); if (permissions.includes('r')) sasPermissions.read = true; if (permissions.includes('w')) sasPermissions.write = true; if (permissions.includes('c')) sasPermissions.create = true; if (permissions.includes('d')) sasPermissions.delete = true; const now = new Date(); const expiresOn = new Date(now.getTime() + expiresInMinutes * 60 * 1000); const sasToken = generateBlobSASQueryParameters( { containerName, blobName, permissions: sasPermissions, startsOn: now, expiresOn, protocol: SASProtocol.Https, }, credential ).toString(); return `https://${credAccountName}.blob.core.windows.net/${containerName}/${blobName}?${sasToken}`; } /** * Check if blob storage is configured. */ export function isBlobStorageConfigured(): boolean { return !!( process.env.AZURE_BLOB_CONNECTION_STRING || (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) ); } /** * Test helper: reset module singletons/caches. */ export function _resetBlobClient(): void { serviceClient = null; containerClients.clear(); }