feat: implement storage provider with local and S3 adapters

This commit is contained in:
2026-04-10 00:59:35 -03:00
parent fb8c07d32e
commit 98515550ca
8 changed files with 2260 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
import {
StorageProvider,
StorageResult,
} from '@/lib/storage/storage.interface';
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { promises as fs } from 'fs';
import path from 'path';
/**
* Local file system storage adapter
* Saves files to the public/uploads directory and returns local paths
*/
export class LocalStorageAdapter implements StorageProvider {
private readonly uploadsDir: string;
constructor(uploadsDir: string = 'public/uploads') {
this.uploadsDir = uploadsDir;
}
async put(
key: string,
file: Buffer | Blob,
_contentType: string
): Promise<StorageResult> {
// Ensure directory exists
await fs.mkdir(this.uploadsDir, { recursive: true });
// Convert Blob to Buffer if needed
const buffer =
file instanceof Blob ? Buffer.from(await file.arrayBuffer()) : file;
// Write file to disk
const filePath = path.join(this.uploadsDir, key);
await fs.writeFile(filePath, buffer);
// Return local path as public URL
const publicUrl = `/${this.uploadsDir}/${key}`;
return {
type: 'local',
provider: null,
key,
publicUrl,
};
}
async delete(key: string): Promise<void> {
const filePath = path.join(this.uploadsDir, key);
try {
await fs.unlink(filePath);
} catch (error) {
// File might not exist, silently ignore
if (
error instanceof Error &&
'code' in error &&
error.code !== 'ENOENT'
) {
throw error;
}
}
}
}
/**
* AWS S3 storage adapter
* Uploads files to S3 and returns public URLs
*/
export class S3StorageAdapter implements StorageProvider {
private readonly s3Client: S3Client;
private readonly bucketName: string;
private readonly region: string;
constructor(
bucketName: string,
region: string = 'us-east-1',
s3Client?: S3Client
) {
this.bucketName = bucketName;
this.region = region;
this.s3Client =
s3Client ||
new S3Client({
region: this.region,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
},
});
}
async put(
key: string,
file: Buffer | Blob,
contentType: string
): Promise<StorageResult> {
// Convert Blob to Buffer if needed
const buffer =
file instanceof Blob ? Buffer.from(await file.arrayBuffer()) : file;
// Upload to S3
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
// Generate public URL
const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
return {
type: 's3',
provider: 'aws-s3',
key,
publicUrl,
};
}
async delete(key: string): Promise<void> {
try {
await this.s3Client.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
})
);
} catch (error) {
// Log error but don't throw to avoid cascading failures
console.error(`Failed to delete S3 object: ${key}`, error);
}
}
}

View File

@@ -0,0 +1,28 @@
import {
LocalStorageAdapter,
S3StorageAdapter,
} from '@/lib/storage/storage.adapter';
import { StorageProvider } from '@/lib/storage/storage.interface';
/**
* Factory function to create the appropriate storage provider based on environment
*/
export function createStorageProvider(): StorageProvider {
const storageType = process.env.STORAGE_TYPE || 'local';
if (storageType === 's3') {
const bucketName = process.env.S3_BUCKET_NAME;
const region = process.env.S3_REGION || 'us-east-1';
if (!bucketName) {
throw new Error(
'S3_BUCKET_NAME environment variable is required when STORAGE_TYPE=s3'
);
}
return new S3StorageAdapter(bucketName, region);
}
// Default to local storage
return new LocalStorageAdapter();
}

View File

@@ -0,0 +1,34 @@
/**
* Result returned from storage operations
*/
export interface StorageResult {
type: 'local' | 's3';
provider: string | null;
key: string;
publicUrl: string;
}
/**
* Storage provider interface for abstracting different storage implementations
*/
export interface StorageProvider {
/**
* Uploads a file to storage
* @param key - The unique key/path for the file
* @param file - The file content as Buffer or Blob
* @param contentType - MIME type of the file
* @returns Promise<StorageResult> - Result containing storage metadata and public URL
*/
put(
key: string,
file: Buffer | Blob,
contentType: string
): Promise<StorageResult>;
/**
* Deletes a file from storage
* @param key - The unique key/path of the file to delete
* @returns Promise<void>
*/
delete(key: string): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { createStorageProvider } from '@/lib/storage/storage.factory';
/**
* Singleton instance of the configured storage provider
*/
export const storage: ReturnType<typeof createStorageProvider> =
createStorageProvider();
export type { StorageResult } from '@/lib/storage/storage.interface';