/** * Storage Service — Cloudflare R2 (S3-compatible) with local /tmp fallback * * Required env vars for R2 mode: * R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_URL * * Falls back to saving files in /tmp in development (returns a file:// path). */ import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; // ─── R2 / S3 Upload ─────────────────────────────────────────────────────────── async function uploadToR2(buffer: Buffer, filename: string, contentType: string): Promise { const accountId = process.env.R2_ACCOUNT_ID!; const bucket = process.env.R2_BUCKET!; const accessKeyId = process.env.R2_ACCESS_KEY_ID!; const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY!; const publicUrl = process.env.R2_PUBLIC_URL!; // e.g. https://pub-xxx.r2.dev // Use native fetch with AWS Signature V4 (no SDK dependency needed for simple PUT) const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3'); const client = new S3Client({ region: 'auto', endpoint: `https://${accountId}.r2.cloudflarestorage.com`, credentials: { accessKeyId, secretAccessKey }, }); await client.send(new PutObjectCommand({ Bucket: bucket, Key: filename, Body: buffer, ContentType: contentType, })); const finalUrl = `${publicUrl.replace(/\/$/, "")}/${filename}`; // ─── Production Hardening: Verify Public Access ───────── try { const check = await fetch(finalUrl, { method: 'HEAD' }); if (!check.ok) { console.warn(`[Storage] ⚠️ Public access check failed for ${finalUrl} (Status: ${check.status})`); } else { console.log(`[Storage] ✅ Verified public access: ${finalUrl}`); } } catch (err: unknown) { console.warn(`[Storage] ⚠️ Could not verify public access for ${finalUrl}: ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))}`); } return finalUrl; } // ─── Local /tmp Fallback ────────────────────────────────────────────────────── async function saveLocally(buffer: Buffer, filename: string): Promise { const tmpPath = path.join('/tmp', filename); await fs.writeFile(tmpPath, buffer); console.warn(`[Storage] R2 not configured — file saved locally to ${tmpPath}`); return `file://${tmpPath}`; } // ─── Public API ─────────────────────────────────────────────────────────────── function isR2Configured(): boolean { return !!( process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY && process.env.R2_BUCKET && process.env.R2_PUBLIC_URL ); } /** * Upload a buffer to Cloudflare R2 (or save locally in dev) and return the public URL. * @param buffer - File contents * @param filename - Filename with extension (e.g. "onepager-1234.pdf") * @param contentType - MIME type (e.g. "application/pdf") */ export async function uploadFile(buffer: Buffer, originalFilename: string, contentType: string): Promise { // ─── Production Hardening: Unique Filenames ───────────── const ext = path.extname(originalFilename); const uniqueName = `${crypto.randomUUID()}-${Date.now()}${ext}`; if (isR2Configured()) { try { return await uploadToR2(buffer, uniqueName, contentType); } catch (err: unknown) { console.error(`[Storage] R2 Upload Failed: ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))}. Falling back to local.`); } } return saveLocally(buffer, uniqueName); }