import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import * as fs from 'fs'; import * as path from 'path'; import { pipeline } from 'stream/promises'; import { Readable } from 'stream'; import { config } from '../config'; const R2_PREFIX = 'r2://'; let client: S3Client | null = null; export interface R2ObjectRef { bucket: string; key: string; } function getR2Client(): S3Client { if (!client) { client = new S3Client({ region: config.r2Region, endpoint: config.r2Endpoint, forcePathStyle: true, credentials: { accessKeyId: config.r2AccessKeyId, secretAccessKey: config.r2SecretAccessKey, }, }); } return client; } export function isR2ObjectRef(value: string): boolean { return value.startsWith(R2_PREFIX); } export function createR2ObjectRef(bucket: string, key: string): string { return `${R2_PREFIX}${bucket}/${key.replace(/^\/+/, '')}`; } export function parseR2ObjectRef(value: string): R2ObjectRef { if (!isR2ObjectRef(value)) { throw new Error('Not an R2 object reference'); } const withoutScheme = value.slice(R2_PREFIX.length); const slashIndex = withoutScheme.indexOf('/'); if (slashIndex <= 0 || slashIndex === withoutScheme.length - 1) { throw new Error('Invalid R2 object reference'); } return { bucket: withoutScheme.slice(0, slashIndex), key: withoutScheme.slice(slashIndex + 1), }; } export async function putR2Buffer( bucket: string, key: string, body: Buffer, contentType: string, ): Promise { await getR2Client().send( new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentLength: body.length, ContentType: contentType, }), ); return createR2ObjectRef(bucket, key); } export async function putR2File( bucket: string, key: string, localPath: string, contentType: string, ): Promise { const stat = await fs.promises.stat(localPath); await getR2Client().send( new PutObjectCommand({ Bucket: bucket, Key: key, Body: fs.createReadStream(localPath), ContentLength: stat.size, ContentType: contentType, }), ); return createR2ObjectRef(bucket, key); } export async function downloadR2File( objectRef: string, localPath: string, ): Promise { const { bucket, key } = parseR2ObjectRef(objectRef); const response = await getR2Client().send( new GetObjectCommand({ Bucket: bucket, Key: key }), ); if (!response.Body) throw new Error(`R2 object body is empty: ${objectRef}`); await fs.promises.mkdir(path.dirname(localPath), { recursive: true }); await pipeline(response.Body as Readable, fs.createWriteStream(localPath)); } export async function readR2Text(objectRef: string): Promise { const { bucket, key } = parseR2ObjectRef(objectRef); try { const response = await getR2Client().send( new GetObjectCommand({ Bucket: bucket, Key: key }), ); if (!response.Body) return null; return await response.Body.transformToString('utf-8'); } catch (error: unknown) { const name = error instanceof Error ? error.name : ''; const status = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata ?.httpStatusCode; if (name === 'NoSuchKey' || name === 'NotFound' || status === 404) return null; throw error; } } export async function deleteR2Object(objectRef: string): Promise { const { bucket, key } = parseR2ObjectRef(objectRef); await getR2Client().send( new DeleteObjectCommand({ Bucket: bucket, Key: key }), ); } export async function createR2SignedDownloadUrl( objectRef: string, expiresInSeconds: number, downloadFileName?: string, ): Promise { const { bucket, key } = parseR2ObjectRef(objectRef); const disposition = downloadFileName ? `attachment; filename*=UTF-8''${encodeURIComponent(downloadFileName)}` : undefined; return getSignedUrl( getR2Client(), new GetObjectCommand({ Bucket: bucket, Key: key, ResponseContentDisposition: disposition, }), { expiresIn: expiresInSeconds }, ); }