From 393bc9a9c6d0703b1768dea8fb13fe746833755a Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sat, 9 May 2026 12:46:47 +0800 Subject: [PATCH] feat: add R2 storage client module with upload, delete, list, metadata --- lib/storage/index.ts | 11 ++++ lib/storage/r2-client.ts | 138 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 lib/storage/index.ts create mode 100644 lib/storage/r2-client.ts diff --git a/lib/storage/index.ts b/lib/storage/index.ts new file mode 100644 index 0000000..cbf771a --- /dev/null +++ b/lib/storage/index.ts @@ -0,0 +1,11 @@ +export { + uploadObject, + deleteObject, + listObjects, + getObjectMetadata, + isR2Configured, + getR2PublicUrl, + isR2Url, + getR2ConfigStatus, +} from "./r2-client" +export type { ObjectInfo, ObjectMetadata } from "./r2-client" \ No newline at end of file diff --git a/lib/storage/r2-client.ts b/lib/storage/r2-client.ts new file mode 100644 index 0000000..9ebe498 --- /dev/null +++ b/lib/storage/r2-client.ts @@ -0,0 +1,138 @@ +import { + S3Client, + PutObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, + HeadObjectCommand, +} from "@aws-sdk/client-s3" + +// ── Configuration ────────────────────────────────────────────────── +const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID +const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID +const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY +const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "deckyvault" +const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? "" + +let _client: S3Client | null = null +let _configured = false + +function getR2ConfigStatus(): { configured: boolean; reason?: string } { + if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) { + return { configured: false, reason: "Missing R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, or R2_SECRET_ACCESS_KEY" } + } + return { configured: true } +} + +function getClient(): S3Client { + if (_client) return _client + const status = getR2ConfigStatus() + if (!status.configured) { + throw new Error(`R2 not configured: ${status.reason}`) + } + _client = new S3Client({ + region: "auto", + endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: R2_ACCESS_KEY_ID!, + secretAccessKey: R2_SECRET_ACCESS_KEY!, + }, + }) + _configured = true + return _client +} + +// ── Upload ────────────────────────────────────────────────────────── +export async function uploadObject( + key: string, + body: Buffer | Uint8Array, + contentType: string, + metadata?: Record, +): Promise { + const client = getClient() + await client.send( + new PutObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + Body: body, + ContentType: contentType, + Metadata: metadata, + }), + ) + return `${R2_PUBLIC_URL}/${key}` +} + +// ── Delete ────────────────────────────────────────────────────────── +export async function deleteObject(key: string): Promise { + const client = getClient() + await client.send( + new DeleteObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + }), + ) +} + +// ── List ──────────────────────────────────────────────────────────── +export interface ObjectInfo { + key: string + size: number + lastModified: Date | undefined +} + +export async function listObjects(prefix?: string): Promise { + const client = getClient() + const result = await client.send( + new ListObjectsV2Command({ + Bucket: R2_BUCKET_NAME, + Prefix: prefix, + MaxKeys: 1000, + }), + ) + return (result.Contents ?? []).map((obj) => ({ + key: obj.Key!, + size: obj.Size ?? 0, + lastModified: obj.LastModified, + })) +} + +// ── Metadata ──────────────────────────────────────────────────────── +export interface ObjectMetadata { + contentType: string + size: number + lastModified: Date | undefined +} + +export async function getObjectMetadata(key: string): Promise { + const client = getClient() + try { + const result = await client.send( + new HeadObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + }), + ) + return { + contentType: result.ContentType ?? "application/octet-stream", + size: result.ContentLength ?? 0, + lastModified: result.LastModified, + } + } catch { + return null + } +} + +// ── Helpers ───────────────────────────────────────────────────────── +export function isR2Configured(): boolean { + return getR2ConfigStatus().configured +} + +export function getR2PublicUrl(): string { + return R2_PUBLIC_URL +} + +export function isR2Url(url: string | null): boolean { + if (!url || !R2_PUBLIC_URL) return false + return url.startsWith(R2_PUBLIC_URL) +} + +export { getR2ConfigStatus } \ No newline at end of file