/** * S3-compatible storage abstraction. * * STORAGE_PROVIDER env controls which backend is used: * local – local filesystem (dev only, no CDN) * uploadthing – UploadThing managed (default for quick start) * s3 – AWS S3 * r2 – Cloudflare R2 * b2 – Backblaze B2 (S3-compatible endpoint) * minio – Self-hosted MinIO */ import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import fs from "fs"; import path from "path"; import { randomUUID } from "crypto"; export type StorageProvider = "local" | "uploadthing" | "s3" | "r2" | "b2" | "minio"; function getProvider(): StorageProvider { return (process.env.STORAGE_PROVIDER as StorageProvider) ?? "uploadthing"; } // ── S3 Client factory ──────────────────────────────────────────────────────── function buildS3Client(): S3Client { const provider = getProvider(); if (provider === "r2") { return new S3Client({ region: "auto", endpoint: `https://${process.env.R2_ACCOUNT_ID!}.r2.cloudflarestorage.com`, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, }); } if (provider === "b2") { return new S3Client({ region: "auto", endpoint: process.env.B2_ENDPOINT!, credentials: { accessKeyId: process.env.B2_APPLICATION_KEY_ID!, secretAccessKey: process.env.B2_APPLICATION_KEY!, }, }); } if (provider === "minio") { return new S3Client({ region: "us-east-1", endpoint: process.env.MINIO_ENDPOINT ?? "http://localhost:9000", forcePathStyle: true, credentials: { accessKeyId: process.env.MINIO_ACCESS_KEY!, secretAccessKey: process.env.MINIO_SECRET_KEY!, }, }); } // Default: AWS S3 return new S3Client({ region: process.env.AWS_REGION ?? "us-east-1", credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, }); } function getBucketName(): string { const provider = getProvider(); const map: Record = { s3: process.env.AWS_BUCKET_NAME, r2: process.env.R2_BUCKET_NAME, b2: process.env.B2_BUCKET_NAME, minio: process.env.MINIO_BUCKET_NAME, }; return map[provider] ?? "vfx-review"; } // ── Public API ─────────────────────────────────────────────────────────────── export interface UploadResult { url: string; key: string; provider: StorageProvider; } /** * Upload a file buffer to the configured storage backend. * Returns the public URL and storage key. */ export async function uploadFile( buffer: Buffer, fileName: string, contentType: string, folder: string = "uploads" ): Promise { const provider = getProvider(); const key = `${folder}/${randomUUID()}-${fileName}`; if (provider === "local") { return uploadLocal(buffer, key); } // All S3-compatible providers share the same logic const client = buildS3Client(); const bucket = getBucketName(); await client.send( new PutObjectCommand({ Bucket: bucket, Key: key, Body: buffer, ContentType: contentType, }) ); const url = getPublicUrl(key, provider); return { url, key, provider }; } /** * Generate a presigned URL for direct browser-to-storage upload. * Expires in 1 hour by default. */ export async function generatePresignedUploadUrl( key: string, contentType: string, expiresIn: number = 3600 ): Promise { const provider = getProvider(); if (provider === "local" || provider === "uploadthing") { // Not applicable for local / uploadthing (use their own flow) throw new Error(`Presigned URLs not supported for provider: ${provider}`); } const client = buildS3Client(); const command = new PutObjectCommand({ Bucket: getBucketName(), Key: key, ContentType: contentType, }); return getSignedUrl(client, command, { expiresIn }); } /** * Generate a presigned download URL for a private object. */ export async function generatePresignedDownloadUrl( key: string, expiresIn: number = 3600 ): Promise { const provider = getProvider(); if (provider === "local") { return `/api/files/${encodeURIComponent(key)}`; } const client = buildS3Client(); const command = new GetObjectCommand({ Bucket: getBucketName(), Key: key, }); return getSignedUrl(client, command, { expiresIn }); } /** * Delete a file from storage by key. */ export async function deleteFile(key: string): Promise { const provider = getProvider(); if (provider === "local") { const filePath = path.join(process.env.LOCAL_UPLOAD_DIR ?? "./uploads", key); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); return; } const client = buildS3Client(); await client.send( new DeleteObjectCommand({ Bucket: getBucketName(), Key: key }) ); } // ── Private helpers ────────────────────────────────────────────────────────── function uploadLocal(buffer: Buffer, key: string): UploadResult { const uploadDir = process.env.LOCAL_UPLOAD_DIR ?? "./uploads"; const filePath = path.join(uploadDir, key); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, buffer); const url = `/api/files/${encodeURIComponent(key)}`; return { url, key, provider: "local" }; } function getPublicUrl(key: string, provider: StorageProvider): string { if (provider === "r2" && process.env.R2_PUBLIC_URL) { return `${process.env.R2_PUBLIC_URL}/${key}`; } if (provider === "s3") { return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION ?? "us-east-1"}.amazonaws.com/${key}`; } if (provider === "minio") { return `${process.env.MINIO_ENDPOINT}/${getBucketName()}/${key}`; } // Fallback return `/${key}`; }