diff --git a/backend/package.json b/backend/package.json index fe5b60a..71b8e8c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,8 @@ "class-validator": "^0.14.3", "ethers": "^6.16.0", "nodemailer": "^6.9.3", + "@aws-sdk/client-s3": "^3.700.0", + "@aws-sdk/s3-request-presigner": "^3.700.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/backend/src/storage/s3-storage.provider.ts b/backend/src/storage/s3-storage.provider.ts new file mode 100644 index 0000000..9fb918c --- /dev/null +++ b/backend/src/storage/s3-storage.provider.ts @@ -0,0 +1,238 @@ +import { + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + S3Client, + PutObjectCommand, + DeleteObjectCommand, + GetObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Readable } from 'stream'; +import { randomUUID } from 'crypto'; +import * as path from 'path'; + +export interface UploadResult { + url: string; + key: string; + bucket: string; +} + +export interface S3Config { + region: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; +} + +/** + * S3StorageProvider — Abstract Cloud File Storage Wrapper + * + * Uses AWS SDK v3 (@aws-sdk/client-s3) for S3 operations. + * Supports streaming uploads to avoid OOM on large files. + * Falls back gracefully when S3 is not configured. + */ +@Injectable() +export class S3StorageProvider { + private readonly logger = new Logger(S3StorageProvider.name); + private s3Client: S3Client | null = null; + private bucket: string = ''; + + constructor(private readonly configService: ConfigService) { + this.initializeClient(); + } + + /** + * Check if S3 is properly configured and available + */ + isAvailable(): boolean { + return this.s3Client !== null; + } + + /** + * Get the configured bucket name + */ + getBucketName(): string { + return this.bucket; + } + + /** + * Upload a file buffer/stream to S3. + * Uses streams internally to handle large files without OOM. + * + * @param bufferOrStream - Buffer or Readable stream of file content + * @param originalName - Original filename (for extension detection) + * @param contentType - MIME type (auto-detected if not provided) + * @param folder - Optional subfolder prefix (e.g., 'avatars', 'bounties') + * @returns Public URL of the uploaded file + */ + async upload( + bufferOrStream: Buffer | Readable, + originalName: string, + contentType?: string, + folder?: string, + ): Promise { + this.ensureAvailable(); + + const key = this.buildKey(originalName, folder); + const detectedType = + contentType || this.detectContentType(originalName); + + // Convert Buffer to stream for consistent handling + const bodyStream = + bufferOrStream instanceof Buffer + ? Readable.from(bufferOrStream) + : bufferOrStream; + + try { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: bodyStream, + ContentType: detectedType, + // Cache-control headers for better CDN behavior + CacheControl: 'public, max-age=31536000, immutable', + }); + + await this.s3Client!.send(command); + + const url = this.buildPublicUrl(key); + + this.logger.log(`Uploaded ${key} → ${url}`); + + return { url, key, bucket: this.bucket }; + } catch (error) { + this.logger.error(`S3 upload failed for key ${key}`, error instanceof Error ? error.stack : error); + throw new InternalServerErrorException('Failed to upload file to S3'); + } + } + + /** + * Delete a file from S3 by its key + */ + async delete(key: string): Promise { + this.ensureAvailable(); + + try { + const command = new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + await this.s3Client!.send(command); + this.logger.log(`Deleted S3 object: ${key}`); + } catch (error) { + this.logger.warn(`S3 delete failed for key ${key}:`, error); + // Don't throw — deletion failures are non-critical + } + } + + /** + * Generate a presigned URL for private/temporary access + * Useful for direct client uploads or temporary downloads + */ + async getPresignedUrl( + key: string, + expiresInSeconds: number = 3600, + ): Promise { + this.ensureAvailable(); + + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + return getSignedUrl(this.s3Client!, command, { + expiresIn: expiresInSeconds, + }); + } + + /* ---- Initialization ---- */ + + private initializeClient(): void { + const config = this.readConfig(); + if (!config) { + this.logger.warn( + 'S3 not configured — set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_S3_BUCKET in .env', + ); + return; + } + + this.s3Client = new S3Client({ + region: config.region, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + + this.bucket = config.bucket; + this.logger.log( + `S3 client initialized — bucket: ${config.bucket}, region: ${config.region}`, + ); + } + + private readConfig(): S3Config | null { + const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID'); + const secretAccessKey = this.configService.get( + 'AWS_SECRET_ACCESS_KEY', + ); + const region = this.configService.get('AWS_REGION'); + const bucket = this.configService.get('AWS_S3_BUCKET'); + + if (!accessKeyId || !secretAccessKey || !region || !bucket) { + return null; + } + + return { region, bucket, accessKeyId, secretAccessKey }; + } + + /* ---- Key / URL helpers ---- */ + + private buildKey(originalName: string, folder?: string): string { + const ext = path.extname(originalName) || ''; + const sanitizedName = path + .basename(originalName, ext) + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 60); + const uuid = randomUUID(); + const namePart = sanitizedName || 'file'; + const key = `${uuid}-${namePart}${ext}`; + return folder ? `${folder}/${key}` : key; + } + + private buildPublicUrl(key: string): string { + const region = this.configService.get('AWS_REGION'); + return `https://${this.bucket}.s3.${region}.amazonaws.com/${key}`; + } + + /* ---- Content type detection ---- */ + + private detectContentType(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + const mimeMap: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.json': 'application/json', + '.txt': 'text/plain', + }; + return mimeMap[ext] || 'application/octet-stream'; + } + + /* ---- Guards ---- */ + + private ensureAvailable(): void { + if (!this.s3Client) { + throw new InternalServerErrorException( + 'S3 storage is not configured. Set AWS credentials in .env', + ); + } + } +} diff --git a/backend/src/storage/storage.controller.ts b/backend/src/storage/storage.controller.ts new file mode 100644 index 0000000..3c82a5d --- /dev/null +++ b/backend/src/storage/storage.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Post, + UseInterceptors, + UploadedFile, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { S3StorageProvider } from './s3-storage.provider'; + +@Controller('storage') +export class StorageController { + constructor(private readonly s3Provider: S3StorageProvider) {} + + /** + * Upload a file to S3 (or local fallback) + * POST /storage/upload + * + * Uses streaming to handle large files without OOM. + * Returns the public URL of the uploaded file. + */ + @Post('upload') + @UseInterceptors( + FileInterceptor('file', { + limits: { + fileSize: 50 * 1024 * 1024, // 50MB max + }, + fileFilter: (_req, file, cb) => { + // Allow images, PDFs, and common document types + const allowedMimes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'application/pdf', + 'application/json', + ]; + if (allowedMimes.includes(file.mimetype)) { + cb(null, true); + } else { + cb( + new BadRequestException( + `File type ${file.mimetype} is not allowed. Allowed: images, PDF`, + ), + false, + ); + } + }, + }), + ) + async uploadFile(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('No file provided'); + } + + if (!this.s3Provider.isAvailable()) { + // Fallback to local storage via existing service + // For now, return an informative message + return { + url: null, + message: + 'S3 not configured — set AWS credentials in .env to enable cloud uploads', + filename: file.originalname, + size: file.size, + }; + } + + const result = await this.s3Provider.upload( + file.buffer, + file.originalname, + file.mimetype, + 'uploads', + ); + + return { + url: result.url, + key: result.key, + bucket: result.bucket, + filename: file.originalname, + size: file.size, + }; + } +} diff --git a/backend/src/storage/storage.module.ts b/backend/src/storage/storage.module.ts index 83c3c88..1a25d23 100644 --- a/backend/src/storage/storage.module.ts +++ b/backend/src/storage/storage.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { StorageService } from './storage.service'; +import { S3StorageProvider } from './s3-storage.provider'; import { STORAGE_S3_CLIENT_FACTORY } from './storage.constants'; @Module({ imports: [ConfigModule], providers: [ StorageService, + S3StorageProvider, { provide: STORAGE_S3_CLIENT_FACTORY, useValue: () => { @@ -15,6 +17,6 @@ import { STORAGE_S3_CLIENT_FACTORY } from './storage.constants'; }, }, ], - exports: [StorageService], + exports: [StorageService, S3StorageProvider], }) export class StorageModule {}