feat: add R2 storage client module with upload, delete, list, metadata
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
uploadObject,
|
||||
deleteObject,
|
||||
listObjects,
|
||||
getObjectMetadata,
|
||||
isR2Configured,
|
||||
getR2PublicUrl,
|
||||
isR2Url,
|
||||
getR2ConfigStatus,
|
||||
} from "./r2-client"
|
||||
export type { ObjectInfo, ObjectMetadata } from "./r2-client"
|
||||
@@ -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<string, string>,
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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<ObjectInfo[]> {
|
||||
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<ObjectMetadata | null> {
|
||||
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 }
|
||||
Reference in New Issue
Block a user