refactor: enhance storage module with S3 provider integration

- Introduced a new S3Provider class for managing file uploads, deletions, and presigned URL generation with Amazon S3.
- Refactored existing storage functions to utilize the new provider, improving modularity and code organization.
- Re-exported types for better accessibility and convenience.
- Implemented robust error handling for storage operations, ensuring clear error messages for configuration and upload issues.
- Updated the storage configuration to support S3-compatible services, enhancing flexibility.
This commit is contained in:
javayhu 2025-03-19 01:10:46 +08:00
parent 130338b9a5
commit 9e1c648a7c
5 changed files with 509 additions and 189 deletions

160
src/storage/README.md Normal file
View File

@ -0,0 +1,160 @@
# Storage Module
This module provides a unified interface for storing and retrieving files using various cloud storage providers. Currently, it supports Amazon S3 and compatible services like Cloudflare R2.
## Features
- Upload files to cloud storage
- Generate pre-signed URLs for direct browser-to-storage uploads
- Delete files from storage
- Client-side upload helpers for both small and large files
## Basic Usage
```typescript
import { uploadFile, deleteFile, getPresignedUploadUrl } from '@/src/storage';
// Upload a file
const { url, key } = await uploadFile(
fileBuffer,
'original-filename.jpg',
'image/jpeg',
'uploads/images'
);
// Delete a file
await deleteFile(key);
// Generate a pre-signed URL for direct upload
const { url, key } = await getPresignedUploadUrl(
'filename.jpg',
'image/jpeg',
'uploads/images'
);
```
## Client-Side Upload
For client-side uploads, use the `uploadFileFromBrowser` function:
```typescript
'use client';
import { uploadFileFromBrowser } from '@/src/storage';
// In your component
async function handleFileUpload(event) {
const file = event.target.files[0];
try {
// This will automatically use the most appropriate upload method
// based on the file size
const { url, key } = await uploadFileFromBrowser(file, 'uploads/images');
console.log('File uploaded:', url);
} catch (error) {
console.error('Upload failed:', error);
}
}
```
## Configuration
The storage module is configured using environment variables:
```
# Required
STORAGE_REGION=us-east-1
STORAGE_ACCESS_KEY_ID=your-access-key
STORAGE_SECRET_ACCESS_KEY=your-secret-key
STORAGE_BUCKET_NAME=your-bucket-name
# Optional
STORAGE_ENDPOINT=https://custom-endpoint.com
STORAGE_PUBLIC_URL=https://cdn.example.com
STORAGE_FORCE_PATH_STYLE=true
```
## Advanced Usage
### Using the Storage Provider Directly
If you need more control, you can interact with the storage provider directly:
```typescript
import { getStorageProvider } from '@/src/storage';
const provider = getStorageProvider();
// Use provider methods directly
const result = await provider.uploadFile({
file: fileBuffer,
filename: 'example.pdf',
contentType: 'application/pdf',
folder: 'documents'
});
```
### Using a Custom Provider Implementation
You can create and use your own storage provider implementation:
```typescript
import { StorageProvider, UploadFileParams, UploadFileResult } from '@/src/storage';
class CustomStorageProvider implements StorageProvider {
// Implement the required methods
async uploadFile(params: UploadFileParams): Promise<UploadFileResult> {
// Your implementation
}
async deleteFile(key: string): Promise<void> {
// Your implementation
}
async getPresignedUploadUrl(params: PresignedUploadUrlParams): Promise<UploadFileResult> {
// Your implementation
}
getProviderName(): string {
return 'CustomProvider';
}
}
// Then use it
const customProvider = new CustomStorageProvider();
const result = await customProvider.uploadFile({
file: fileBuffer,
filename: 'example.jpg',
contentType: 'image/jpeg'
});
```
## API Reference
### Main Functions
- `uploadFile(file, filename, contentType, folder?)`: Upload a file to storage
- `deleteFile(key)`: Delete a file from storage
- `getPresignedUploadUrl(filename, contentType, folder?, expiresIn?)`: Generate a pre-signed URL
- `uploadFileFromBrowser(file, folder?)`: Upload a file from the browser
### Provider Interface
The `StorageProvider` interface defines the following methods:
- `uploadFile(params)`: Upload a file to storage
- `deleteFile(key)`: Delete a file from storage
- `getPresignedUploadUrl(params)`: Generate a pre-signed URL
- `getProviderName()`: Get the provider name
### Configuration
The `StorageConfig` interface defines the configuration options:
- `region`: Storage region (e.g., 'us-east-1')
- `endpoint?`: Custom endpoint URL for S3-compatible services
- `accessKeyId`: Access key ID for authentication
- `secretAccessKey`: Secret access key for authentication
- `bucketName`: Storage bucket name
- `publicUrl?`: Public URL for accessing files
- `forcePathStyle?`: Whether to use path-style URLs

View File

@ -0,0 +1,16 @@
import { StorageConfig } from '../types';
/**
* Default storage configuration
*
* This configuration is loaded from environment variables
*/
export const storageConfig: StorageConfig = {
region: process.env.STORAGE_REGION || '',
endpoint: process.env.STORAGE_ENDPOINT,
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || '',
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || '',
bucketName: process.env.STORAGE_BUCKET_NAME || '',
publicUrl: process.env.STORAGE_PUBLIC_URL,
forcePathStyle: process.env.STORAGE_FORCE_PATH_STYLE !== 'false',
};

View File

@ -1,13 +1,55 @@
import { storageConfig } from './config/storage-config';
import { S3Provider } from './provider/s3';
import {
uploadFile as s3UploadFile,
deleteFile as s3DeleteFile,
getPresignedUploadUrl as s3GetPresignedUploadUrl,
StorageError,
ConfigurationError,
UploadError
} from './provider/s3';
PresignedUploadUrlParams,
StorageConfig,
StorageError,
StorageProvider,
UploadError,
UploadFileParams,
UploadFileResult
} from './types';
export { StorageError, ConfigurationError, UploadError };
// Re-export types for convenience
export { ConfigurationError, StorageError, UploadError };
export type {
PresignedUploadUrlParams, StorageConfig, StorageProvider, UploadFileParams,
UploadFileResult
};
/**
* Default storage configuration
*/
export const defaultStorageConfig: StorageConfig = storageConfig;
/**
* Global storage provider instance
*/
let storageProvider: StorageProvider | null = null;
/**
* Initialize the storage provider
* @returns initialized storage provider
*/
export const initializeStorageProvider = (): StorageProvider => {
if (!storageProvider) {
storageProvider = new S3Provider();
}
return storageProvider;
};
/**
* Get the storage provider
* @returns current storage provider instance
* @throws Error if provider is not initialized
*/
export const getStorageProvider = (): StorageProvider => {
if (!storageProvider) {
return initializeStorageProvider();
}
return storageProvider;
};
/**
* Uploads a file to the configured storage provider
@ -23,8 +65,9 @@ export const uploadFile = async (
filename: string,
contentType: string,
folder?: string
): Promise<{ url: string; key: string }> => {
return s3UploadFile(file, filename, contentType, folder);
): Promise<UploadFileResult> => {
const provider = getStorageProvider();
return provider.uploadFile({ file, filename, contentType, folder });
};
/**
@ -34,7 +77,8 @@ export const uploadFile = async (
* @returns Promise that resolves when the file is deleted
*/
export const deleteFile = async (key: string): Promise<void> => {
return s3DeleteFile(key);
const provider = getStorageProvider();
return provider.deleteFile(key);
};
/**
@ -51,8 +95,9 @@ export const getPresignedUploadUrl = async (
contentType: string,
folder?: string,
expiresIn: number = 3600
): Promise<{ url: string; key: string }> => {
return s3GetPresignedUploadUrl(filename, contentType, folder, expiresIn);
): Promise<UploadFileResult> => {
const provider = getStorageProvider();
return provider.getPresignedUploadUrl({ filename, contentType, folder, expiresIn });
};
/**
@ -66,7 +111,7 @@ export const getPresignedUploadUrl = async (
export const uploadFileFromBrowser = async (
file: File,
folder?: string
): Promise<{ url: string; key: string }> => {
): Promise<UploadFileResult> => {
try {
// For small files (< 10MB), use direct upload
if (file.size < 10 * 1024 * 1024) {

View File

@ -1,197 +1,207 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
// Define error types for better error handling
export class StorageError extends Error {
constructor(message: string) {
super(message);
this.name = 'StorageError';
}
}
export class ConfigurationError extends StorageError {
constructor(message: string) {
super(message);
this.name = 'ConfigurationError';
}
}
export class UploadError extends StorageError {
constructor(message: string) {
super(message);
this.name = 'UploadError';
}
}
import { storageConfig } from '../config/storage-config';
import {
ConfigurationError,
PresignedUploadUrlParams,
StorageConfig,
StorageError,
StorageProvider,
UploadError,
UploadFileParams,
UploadFileResult
} from '../types';
/**
* S3 client configuration
* Amazon S3 storage provider implementation
*
* This provider works with Amazon S3 and compatible services like Cloudflare R2
* https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
* https://www.npmjs.com/package/@aws-sdk/client-s3
* https://www.cloudflare.com/lp/pg-cloudflare-r2-vs-aws-s3/
* https://docs.uploadthing.com/uploading-files
* https://developers.cloudflare.com/r2/
*/
const getS3Client = (): S3Client => {
const region = process.env.STORAGE_REGION;
const endpoint = process.env.STORAGE_ENDPOINT;
export class S3Provider implements StorageProvider {
private config: StorageConfig;
private s3Client: S3Client | null = null;
// TODO: set region to 'auto' if not set???
if (!region) {
throw new ConfigurationError('STORAGE_REGION environment variable is not set');
constructor(config: StorageConfig = storageConfig) {
this.config = config;
}
const clientOptions: any = {
region,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || '',
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || '',
},
};
// Add custom endpoint for S3-compatible services like Cloudflare R2
if (endpoint) {
clientOptions.endpoint = endpoint;
// For services like R2 that don't use path-style URLs
if (process.env.STORAGE_FORCE_PATH_STYLE === 'false') {
clientOptions.forcePathStyle = false;
} else {
clientOptions.forcePathStyle = true;
}
}
return new S3Client(clientOptions);
};
// Generate a unique filename with the original extension
const generateUniqueFilename = (originalFilename: string): string => {
const extension = originalFilename.split('.').pop() || '';
const uuid = randomUUID();
return `${uuid}${extension ? `.${extension}` : ''}`;
};
// Upload a file to S3
export const uploadFile = async (
file: Buffer | Blob,
originalFilename: string,
contentType: string,
folder?: string
): Promise<{ url: string; key: string }> => {
try {
const s3 = getS3Client();
const bucket = process.env.STORAGE_BUCKET_NAME;
if (!bucket) {
console.error('STORAGE_BUCKET_NAME environment variable is not set');
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
/**
* Get the S3 client instance
*/
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
}
const filename = generateUniqueFilename(originalFilename);
const key = folder ? `${folder}/${filename}` : filename;
const { region, endpoint, accessKeyId, secretAccessKey, forcePathStyle } = this.config;
// Convert Blob to Buffer if needed
let fileBuffer: Buffer;
if (file instanceof Blob) {
fileBuffer = Buffer.from(await file.arrayBuffer());
} else {
fileBuffer = file;
if (!region) {
throw new ConfigurationError('Storage region is not configured');
}
// Upload the file
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fileBuffer,
ContentType: contentType,
});
await s3.send(command);
// Generate the URL
const publicUrl = process.env.STORAGE_PUBLIC_URL;
let url: string;
if (publicUrl) {
// Use custom domain if provided
url = `${publicUrl.replace(/\/$/, '')}/${key}`;
console.log('uploadFile, public url', url);
} else {
// Generate a pre-signed URL if no public URL is provided
const getCommand = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
url = await getSignedUrl(s3, getCommand, { expiresIn: 3600 * 24 * 7 }); // 7 days
console.log('uploadFile, signed url', url);
}
return { url, key };
} catch (error) {
if (error instanceof ConfigurationError) {
console.error('ConfigurationError', error.message);
throw error;
}
const message = error instanceof Error ? error.message : 'Unknown error occurred during file upload';
throw new UploadError(message);
}
};
// Delete a file from S3
export const deleteFile = async (key: string): Promise<void> => {
try {
const s3 = getS3Client();
const bucket = process.env.STORAGE_BUCKET_NAME;
if (!bucket) {
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
}
const command = {
Bucket: bucket,
Key: key,
const clientOptions: any = {
region,
credentials: {
accessKeyId,
secretAccessKey,
},
};
await s3.send(new PutObjectCommand({
...command,
Body: '',
}));
} catch (error) {
console.error('deleteFile', error);
const message = error instanceof Error ? error.message : 'Unknown error occurred during file deletion';
throw new StorageError(message);
}
};
// Generate a pre-signed URL for direct browser uploads
export const getPresignedUploadUrl = async (
filename: string,
contentType: string,
folder?: string,
expiresIn: number = 3600 // 1 hour default
): Promise<{ url: string; key: string }> => {
try {
const s3 = getS3Client();
const bucket = process.env.STORAGE_BUCKET_NAME;
if (!bucket) {
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
// Add custom endpoint for S3-compatible services like Cloudflare R2
if (endpoint) {
clientOptions.endpoint = endpoint;
clientOptions.forcePathStyle = forcePathStyle !== false;
}
const key = folder ? `${folder}/${filename}` : filename;
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn });
console.log('getPresignedUploadUrl', url);
return { url, key };
} catch (error) {
console.error('getPresignedUploadUrl', error);
const message = error instanceof Error ? error.message : 'Unknown error occurred while generating presigned URL';
throw new StorageError(message);
this.s3Client = new S3Client(clientOptions);
return this.s3Client;
}
};
/**
* Generate a unique filename with the original extension
*/
private generateUniqueFilename(originalFilename: string): string {
const extension = originalFilename.split('.').pop() || '';
const uuid = randomUUID();
return `${uuid}${extension ? `.${extension}` : ''}`;
}
/**
* Upload a file to S3
*/
public async uploadFile(params: UploadFileParams): Promise<UploadFileResult> {
try {
const { file, filename, contentType, folder } = params;
const s3 = this.getS3Client();
const { bucketName } = this.config;
if (!bucketName) {
throw new ConfigurationError('Storage bucket name is not configured');
}
const uniqueFilename = this.generateUniqueFilename(filename);
const key = folder ? `${folder}/${uniqueFilename}` : uniqueFilename;
// Convert Blob to Buffer if needed
let fileBuffer: Buffer;
if (file instanceof Blob) {
fileBuffer = Buffer.from(await file.arrayBuffer());
} else {
fileBuffer = file;
}
// Upload the file
const command = new PutObjectCommand({
Bucket: bucketName,
Key: key,
Body: fileBuffer,
ContentType: contentType,
});
await s3.send(command);
// Generate the URL
const { publicUrl } = this.config;
let url: string;
if (publicUrl) {
// Use custom domain if provided
url = `${publicUrl.replace(/\/$/, '')}/${key}`;
console.log('uploadFile, public url', url);
} else {
// Generate a pre-signed URL if no public URL is provided
const getCommand = new GetObjectCommand({
Bucket: bucketName,
Key: key,
});
url = await getSignedUrl(s3, getCommand, { expiresIn: 3600 * 24 * 7 }); // 7 days
console.log('uploadFile, signed url', url);
}
return { url, key };
} catch (error) {
if (error instanceof ConfigurationError) {
console.error('uploadFile, configuration error', error);
throw error;
}
const message = error instanceof Error ? error.message : 'Unknown error occurred during file upload';
console.error('uploadFile, error', message);
throw new UploadError(message);
}
}
/**
* Delete a file from S3
*/
public async deleteFile(key: string): Promise<void> {
try {
const s3 = this.getS3Client();
const { bucketName } = this.config;
if (!bucketName) {
throw new ConfigurationError('Storage bucket name is not configured');
}
const command = {
Bucket: bucketName,
Key: key,
};
await s3.send(new PutObjectCommand({
...command,
Body: '',
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred during file deletion';
console.error('deleteFile, error', message);
throw new StorageError(message);
}
}
/**
* Generate a pre-signed URL for direct browser uploads
*/
public async getPresignedUploadUrl(params: PresignedUploadUrlParams): Promise<UploadFileResult> {
try {
const { filename, contentType, folder, expiresIn = 3600 } = params;
const s3 = this.getS3Client();
const { bucketName } = this.config;
if (!bucketName) {
throw new ConfigurationError('Storage bucket name is not configured');
}
const uniqueFilename = this.generateUniqueFilename(filename);
const key = folder ? `${folder}/${uniqueFilename}` : uniqueFilename;
const command = new PutObjectCommand({
Bucket: bucketName,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn });
return { url, key };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred while generating presigned URL';
console.error('getPresignedUploadUrl, error', message);
throw new StorageError(message);
}
}
/**
* Get the provider name
*/
public getProviderName(): string {
return 'S3';
}
}
// For backward compatibility
export { ConfigurationError, StorageError, UploadError } from '../types';

89
src/storage/types.ts Normal file
View File

@ -0,0 +1,89 @@
/**
* Storage configuration
*/
export interface StorageConfig {
region: string;
endpoint?: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
publicUrl?: string;
forcePathStyle?: boolean;
}
/**
* Storage provider error types
*/
export class StorageError extends Error {
constructor(message: string) {
super(message);
this.name = 'StorageError';
}
}
export class ConfigurationError extends StorageError {
constructor(message: string) {
super(message);
this.name = 'ConfigurationError';
}
}
export class UploadError extends StorageError {
constructor(message: string) {
super(message);
this.name = 'UploadError';
}
}
/**
* Upload file parameters
*/
export interface UploadFileParams {
file: Buffer | Blob;
filename: string;
contentType: string;
folder?: string;
}
/**
* Upload file result
*/
export interface UploadFileResult {
url: string;
key: string;
}
/**
* Presigned upload URL parameters
*/
export interface PresignedUploadUrlParams {
filename: string;
contentType: string;
folder?: string;
expiresIn?: number;
}
/**
* Storage provider interface
*/
export interface StorageProvider {
/**
* Upload a file to storage
*/
uploadFile(params: UploadFileParams): Promise<UploadFileResult>;
/**
* Delete a file from storage
*/
deleteFile(key: string): Promise<void>;
/**
* Generate a pre-signed URL for client-side uploads
*/
getPresignedUploadUrl(params: PresignedUploadUrlParams): Promise<UploadFileResult>;
/**
* Get the provider's name
*/
getProviderName(): string;
}