// Media storage abstraction with two implementations: // - local: writes to the ./uploads directory and serves via /uploads/* (the // original behavior, and the zero-config default) // - s3: stores objects in an S3-compatible bucket (e.g. Garage), so uploads are // shared across instances instead of living on one container's local disk // // S3 is enabled only when S3_ENDPOINT and S3_BUCKET are set. With it unset the // app behaves exactly as before. A "key" is the object name (e.g. "abc123.jpg"). import { writeFile, mkdir, unlink } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; const UPLOAD_DIR = './uploads'; export interface Storage { readonly backend: 'local' | 's3'; put(key: string, buffer: Buffer, contentType: string): Promise; delete(key: string): Promise; // Public URL to persist as the media record's fileUrl. publicUrl(key: string): string; } /** Whether S3-compatible storage is configured. */ export function isS3Enabled(): boolean { return !!(process.env.S3_ENDPOINT && process.env.S3_BUCKET); } /** Extract the storage key (object name) from a stored fileUrl. */ export function keyFromUrl(fileUrl: string): string { return fileUrl.split('/').pop() || fileUrl; } // ==================== Local implementation ==================== class LocalStorage implements Storage { readonly backend = 'local' as const; private async ensureDir(): Promise { if (!existsSync(UPLOAD_DIR)) { await mkdir(UPLOAD_DIR, { recursive: true }); } } async put(key: string, buffer: Buffer): Promise { await this.ensureDir(); await writeFile(join(UPLOAD_DIR, key), buffer); } async delete(key: string): Promise { const filepath = join(UPLOAD_DIR, key); if (existsSync(filepath)) { await unlink(filepath); } } publicUrl(key: string): string { return `/uploads/${key}`; } } // ==================== S3 implementation ==================== // Imported lazily so the AWS SDK is only loaded when S3 is actually configured. type S3ClientType = import('@aws-sdk/client-s3').S3Client; class S3Storage implements Storage { readonly backend = 's3' as const; private client: S3ClientType | null = null; private bucket = process.env.S3_BUCKET as string; private async getClient(): Promise { if (this.client) return this.client; const { S3Client } = await import('@aws-sdk/client-s3'); const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE || 'true') !== 'false'; this.client = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || 'us-east-1', forcePathStyle, credentials: process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY ? { accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, } : undefined, }); return this.client; } async put(key: string, buffer: Buffer, contentType: string): Promise { const client = await this.getClient(); const { PutObjectCommand } = await import('@aws-sdk/client-s3'); await client.send( new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: buffer, ContentType: contentType, }) ); } async delete(key: string): Promise { const client = await this.getClient(); const { DeleteObjectCommand } = await import('@aws-sdk/client-s3'); await client.send( new DeleteObjectCommand({ Bucket: this.bucket, Key: key, }) ); } publicUrl(key: string): string { // Prefer an explicit public base URL (e.g. a CDN or Garage web endpoint). const base = process.env.S3_PUBLIC_URL; if (base) { return `${base.replace(/\/$/, '')}/${key}`; } // Fall back to a path-style URL against the configured endpoint. const endpoint = (process.env.S3_ENDPOINT || '').replace(/\/$/, ''); return `${endpoint}/${this.bucket}/${key}`; } } // ==================== Selection ==================== let instance: Storage | null = null; export function getStorage(): Storage { if (!instance) { instance = isS3Enabled() ? new S3Storage() : new LocalStorage(); } return instance; }