refactor(storage): migrate to storage abstraction
This commit is contained in:
parent
8315814fd9
commit
7ca2139418
@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, ContainerClient>();
|
||||
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<StorageProvider> {
|
||||
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<ContainerClient> {
|
||||
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<StorageBucket> {
|
||||
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<string> {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
export {
|
||||
BLOB_CONTAINERS,
|
||||
type BlobContainerName,
|
||||
getBlobServiceClient,
|
||||
getContainerClient,
|
||||
getBucket,
|
||||
generateSasUrl,
|
||||
isBlobStorageConfigured,
|
||||
} from '@bytelyst/blob';
|
||||
|
||||
@ -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 ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user