diff --git a/packages/blob/package.json b/packages/blob/package.json index d8e479c1..48713cda 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/blob", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "exports": { ".": { @@ -17,7 +17,7 @@ "build": "tsc", "test": "vitest run" }, - "peerDependencies": { - "@azure/storage-blob": ">=12.0.0" + "dependencies": { + "@bytelyst/storage": "workspace:*" } } diff --git a/packages/blob/src/blob.ts b/packages/blob/src/blob.ts index 001c9a7d..4d095bc1 100644 --- a/packages/blob/src/blob.ts +++ b/packages/blob/src/blob.ts @@ -1,26 +1,22 @@ /** - * Shared Azure Blob Storage utilities. + * Shared Blob Storage utilities. * - * Provides singleton BlobServiceClient, container helpers, and SAS token generation. - * Containers are lazily created on first access (createIfNotExists). + * Delegates to @bytelyst/storage for provider-agnostic blob operations. + * Keeps the same exported API surface for backward compatibility. * * Expected env vars: - * AZURE_BLOB_CONNECTION_STRING — full connection string (preferred) + * STORAGE_PROVIDER — 'azure' (default) | 'memory' + * AZURE_BLOB_CONNECTION_STRING — full connection string (preferred, when provider=azure) * — 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(); + getStorage, + _resetStorage, + type StorageProvider, + type StorageBucket, +} from '@bytelyst/storage'; /** * Known blob containers and their purposes. @@ -40,112 +36,49 @@ export const BLOB_CONTAINERS = { export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS]; /** - * Get or create the BlobServiceClient singleton. + * Get the storage provider 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' - ); +export async function getStorageProvider(): Promise { + return getStorage(); } /** - * Get a container client, creating the container if it doesn't exist. + * Get a bucket (container) by name. */ -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; +export async function getBucket(containerName: string): Promise { + const storage = await getStorage(); + return storage.getBucket(containerName); } /** - * Generate a SAS URL for direct browser upload (or download). + * Generate a signed 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 + * @returns Full signed URL for the blob */ -export function generateSasUrl( +export async 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}`; +): Promise { + const bucket = await getBucket(containerName); + const perm = permissions.includes('w') ? ('write' as const) : ('read' as const); + return bucket.getSignedUrl(blobName, { + permissions: perm, + expiresIn: expiresInMinutes * 60, + }); } /** * Check if blob storage is configured. */ export function isBlobStorageConfigured(): boolean { + const provider = process.env.STORAGE_PROVIDER || 'azure'; + if (provider === 'memory') return true; return !!( process.env.AZURE_BLOB_CONNECTION_STRING || (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) @@ -156,6 +89,5 @@ export function isBlobStorageConfigured(): boolean { * Test helper: reset module singletons/caches. */ export function _resetBlobClient(): void { - serviceClient = null; - containerClients.clear(); + _resetStorage(); } diff --git a/services/platform-service/package.json b/services/platform-service/package.json index b1175c80..7575699c 100644 --- a/services/platform-service/package.json +++ b/services/platform-service/package.json @@ -14,9 +14,9 @@ }, "dependencies": { "@azure/cosmos": "^4.2.0", - "@azure/storage-blob": "^12.31.0", "@bytelyst/auth": "workspace:*", "@bytelyst/blob": "workspace:*", + "@bytelyst/storage": "workspace:*", "@bytelyst/config": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/datastore": "workspace:*", diff --git a/services/platform-service/src/lib/blob.ts b/services/platform-service/src/lib/blob.ts index 98e07a0e..71e8daea 100644 --- a/services/platform-service/src/lib/blob.ts +++ b/services/platform-service/src/lib/blob.ts @@ -1,8 +1,7 @@ export { BLOB_CONTAINERS, type BlobContainerName, - getBlobServiceClient, - getContainerClient, + getBucket, generateSasUrl, isBlobStorageConfigured, } from '@bytelyst/blob'; diff --git a/services/platform-service/src/modules/blob/routes.ts b/services/platform-service/src/modules/blob/routes.ts index 333b3cf6..6420df08 100644 --- a/services/platform-service/src/modules/blob/routes.ts +++ b/services/platform-service/src/modules/blob/routes.ts @@ -12,7 +12,7 @@ import type { FastifyInstance } from 'fastify'; import { verifyToken } from '../auth/jwt.js'; import { BadRequestError, UnauthorizedError, NotFoundError } from '../../lib/errors.js'; import { - getContainerClient, + getBucket, generateSasUrl, isBlobStorageConfigured, BLOB_CONTAINERS, @@ -83,7 +83,7 @@ export async function blobRoutes(app: FastifyInstance) { } } - const sasUrl = generateSasUrl(container, blobName, permissions, expiresInMinutes); + const sasUrl = await generateSasUrl(container, blobName, permissions, expiresInMinutes); return { sasUrl, container, @@ -113,23 +113,18 @@ export async function blobRoutes(app: FastifyInstance) { ? requestedPrefix : `${userPrefix({ productId: auth.productId, sub: auth.sub })}${requestedPrefix ?? ''}`; - const containerClient = await getContainerClient(container); + const bucket = await getBucket(container); + const allBlobs = await bucket.list(effectivePrefix || undefined); - const blobs: BlobInfo[] = []; - let count = 0; - for await (const blob of containerClient.listBlobsFlat({ prefix: effectivePrefix || undefined })) { - if (count >= limit) break; - blobs.push({ - name: blob.name, - container, - contentType: blob.properties.contentType, - size: blob.properties.contentLength ?? 0, - lastModified: blob.properties.lastModified, - url: `${containerClient.url}/${blob.name}`, - metadata: blob.metadata ?? {}, - }); - count++; - } + const blobs: BlobInfo[] = allBlobs.slice(0, limit).map(blob => ({ + name: blob.key, + container, + contentType: blob.contentType, + size: blob.size ?? 0, + lastModified: blob.lastModified, + url: blob.key, + metadata: blob.metadata ?? {}, + })); return { blobs, count: blobs.length, container, prefix: effectivePrefix || null }; }); @@ -146,15 +141,14 @@ export async function blobRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { container, blobName } = parsed.data; - const containerClient = await getContainerClient(container); - const blobClient = containerClient.getBlobClient(blobName); + const bucket = await getBucket(container); - const exists = await blobClient.exists(); + const exists = await bucket.exists(blobName); if (!exists) { throw new NotFoundError(`Blob not found: ${container}/${blobName}`); } - await blobClient.delete(); + await bucket.delete(blobName); return { success: true, container, blobName }; }); @@ -178,23 +172,23 @@ export async function blobRoutes(app: FastifyInstance) { } } - const containerClient = await getContainerClient(container); - const blobClient = containerClient.getBlobClient(blobName); + const bucket = await getBucket(container); - const exists = await blobClient.exists(); + const exists = await bucket.exists(blobName); if (!exists) { throw new NotFoundError(`Blob not found: ${container}/${blobName}`); } - const props = await blobClient.getProperties(); + const blobs = await bucket.list(blobName); + const meta = blobs.find(b => b.key === blobName); return { name: blobName, container, - contentType: props.contentType, - size: props.contentLength, - lastModified: props.lastModified, - url: blobClient.url, - metadata: props.metadata ?? {}, + contentType: meta?.contentType, + size: meta?.size ?? 0, + lastModified: meta?.lastModified, + url: blobName, + metadata: meta?.metadata ?? {}, }; });