/** * Cloudflare R2 storage utilities for Mike document management. * R2 is S3-compatible — uses @aws-sdk/client-s3. * * Required env vars: * R2_ENDPOINT_URL — https://.r2.cloudflarestorage.com * R2_ACCESS_KEY_ID — R2 API token (Access Key ID) * R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key) * R2_BUCKET_NAME — bucket name (default: "mike") */ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; function getClient(): S3Client { return new S3Client({ region: "auto", endpoint: process.env.R2_ENDPOINT_URL!, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, }); } const BUCKET = process.env.R2_BUCKET_NAME ?? "mike"; export const storageEnabled = Boolean( process.env.R2_ENDPOINT_URL && process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY, ); // --------------------------------------------------------------------------- // Upload // --------------------------------------------------------------------------- export async function uploadFile( key: string, content: ArrayBuffer, contentType: string, ): Promise { const client = getClient(); await client.send( new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: Buffer.from(content), ContentType: contentType, }), ); } // --------------------------------------------------------------------------- // Download // --------------------------------------------------------------------------- export async function downloadFile(key: string): Promise { if (!storageEnabled) return null; try { const client = getClient(); const response = await client.send( new GetObjectCommand({ Bucket: BUCKET, Key: key }), ); if (!response.Body) return null; const bytes = await response.Body.transformToByteArray(); return bytes.buffer as ArrayBuffer; } catch { return null; } } // --------------------------------------------------------------------------- // Delete // --------------------------------------------------------------------------- export async function deleteFile(key: string): Promise { if (!storageEnabled) return; const client = getClient(); await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key })); } // --------------------------------------------------------------------------- // Signed URL (pre-signed for temporary direct access) // --------------------------------------------------------------------------- export async function getSignedUrl( key: string, expiresIn = 3600, downloadFilename?: string, ): Promise { if (!storageEnabled) return null; try { const client = getClient(); // Override the response Content-Disposition so the browser uses this // filename on download, instead of the last path segment of the R2 key // (which includes the document UUID). The `download` attribute on // is ignored for cross-origin URLs, so we have to set it server-side. const responseContentDisposition = downloadFilename ? buildContentDisposition("attachment", downloadFilename) : undefined; const command = new GetObjectCommand({ Bucket: BUCKET, Key: key, ResponseContentDisposition: responseContentDisposition, }); return await awsGetSignedUrl(client, command, { expiresIn }); } catch { return null; } } export function normalizeDownloadFilename(name: string): string { const trimmed = name.trim(); const base = trimmed || "download"; return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_"); } export function sanitizeDispositionFilename(name: string): string { return normalizeDownloadFilename(name) .replace(/["\\]/g, "_") .replace(/[^\x20-\x7E]/g, "_"); } export function encodeRFC5987(str: string): string { return encodeURIComponent(str).replace( /['()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), ); } export function buildContentDisposition( kind: "inline" | "attachment", filename: string, ): string { const normalized = normalizeDownloadFilename(filename); return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`; } // --------------------------------------------------------------------------- // Storage key helpers // --------------------------------------------------------------------------- export function storageKey( userId: string, docId: string, filename: string, ): string { return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`; } export function pdfStorageKey( userId: string, docId: string, stem: string, ): string { return `documents/${userId}/${docId}/${stem}.pdf`; } export function generatedDocKey( userId: string, docId: string, filename: string, ): string { return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`; } export function versionStorageKey( userId: string, docId: string, versionSlug: string, filename: string, ): string { return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`; } function storageExtension(filename: string, fallback: string): string { const lastDot = filename.lastIndexOf("."); if (lastDot < 0) return fallback; const ext = filename.slice(lastDot).toLowerCase(); return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback; }