File size: 4,321 Bytes
de6a95b 0349430 538f814 0349430 538f814 de6a95b 538f814 de6a95b 538f814 d9879cf de6a95b 538f814 0349430 de6a95b 0349430 6019d2d 0349430 6019d2d 0349430 6019d2d 538f814 6019d2d 538f814 0349430 538f814 d9879cf 6019d2d 538f814 0349430 538f814 0349430 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | import { logger } from '../logger';
/**
* 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<string> {
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) {
logger.warn(`[Storage] β οΈ Public access check failed for ${finalUrl} (Status: ${check.status})`);
} else {
logger.info(`[Storage] β
Verified public access: ${finalUrl}`);
}
} catch (err: unknown) {
logger.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<string> {
const tmpPath = path.join('/tmp', filename);
await fs.writeFile(tmpPath, buffer);
logger.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 originalFilename - Filename with extension (e.g. "onepager-1234.pdf")
* @param contentType - MIME type (e.g. "application/pdf")
* @param organizationId - Optional organization ID for folder isolation
*/
export async function uploadFile(buffer: Buffer, originalFilename: string, contentType: string, organizationId?: string): Promise<string> {
// βββ Production Hardening: Unique Filenames with Org Isolation βββββββββββ
const ext = path.extname(originalFilename);
const prefix = organizationId ? `orgs/${organizationId}/` : '';
const uniqueName = `${prefix}${crypto.randomUUID()}-${Date.now()}${ext}`;
if (isR2Configured()) {
try {
return await uploadToR2(buffer, uniqueName, contentType);
} catch (err: unknown) {
logger.error(`[Storage] R2 Upload Failed: ${(err instanceof Error ? err.message : String(err))}. Falling back to local.`);
}
}
return saveLocally(buffer, uniqueName);
}
|