feat: integrate S3 storage adapter and update file upload functionality

This commit is contained in:
2026-04-10 22:54:46 -03:00
parent 98515550ca
commit 4b1bd056fc
14 changed files with 319 additions and 309 deletions

View File

@@ -1,69 +1,25 @@
import {
StorageProvider,
StorageResult,
} from '@/lib/storage/storage.interface';
import { StorageProvider } from '@/lib/storage/storage.interface';
import { TypedResult, wrap } from '@/utils/types/results';
import {
DeleteObjectCommand,
ObjectCannedACL,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { promises as fs } from 'fs';
import path from 'path';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { z } from 'zod';
/**
* Local file system storage adapter
* Saves files to the public/uploads directory and returns local paths
* Configuration for S3 storage adapter
*/
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;
}
}
}
}
export const S3StorageConfig = z.object({
endpoint: z.string(),
bucket: z.string(),
region: z.string(),
accessKey: z.string(),
secretKey: z.string(),
});
export type S3StorageConfig = z.infer<typeof S3StorageConfig>;
/**
* AWS S3 storage adapter
@@ -71,68 +27,71 @@ export class LocalStorageAdapter implements StorageProvider {
*/
export class S3StorageAdapter implements StorageProvider {
private readonly s3Client: S3Client;
private readonly endpoint: string;
private readonly bucketName: string;
private readonly region: string;
constructor(
bucketName: string,
region: string = 'us-east-1',
s3Client?: S3Client
) {
this.bucketName = bucketName;
this.region = region;
readonly get: (
...args: Parameters<StorageProvider['get']>
) => Promise<TypedResult<string>>;
readonly put: (
...args: Parameters<StorageProvider['put']>
) => Promise<TypedResult<string>>;
readonly delete: (
...args: Parameters<StorageProvider['delete']>
) => Promise<TypedResult<void>>;
constructor(config: S3StorageConfig, s3Client?: S3Client) {
this.endpoint = config.endpoint;
this.bucketName = config.bucket;
this.s3Client =
s3Client ||
new S3Client({
region: this.region,
endpoint: config.endpoint,
region: config.region,
forcePathStyle: true,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey,
},
});
this.get = wrap(this._get.bind(this));
this.put = wrap(this._put.bind(this));
this.delete = wrap(this._delete.bind(this));
}
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;
private async _get(key: string): Promise<string> {
return `${this.endpoint}/${this.bucketName}/${key}`;
}
// Upload to S3
private async _put(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
ContentType: contentType,
ACL: ObjectCannedACL.public_read,
});
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
}
private async _delete(key: string): Promise<void> {
await this.s3Client.send(
new PutObjectCommand({
new DeleteObjectCommand({
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);
}
}
}
export const new_s3_storage_adapter = (): S3StorageAdapter => {
const config = S3StorageConfig.parse({
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET_NAME,
region: process.env.S3_REGION,
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
});
return new S3StorageAdapter(config);
};