Files
reader-api/lib/r2.ts
T

141 lines
3.7 KiB
TypeScript

import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import path from "path"
type UploadToR2Options = {
buffer: Buffer
contentType?: string | null
keyPrefix?: string
fileNameHint?: string
}
let cachedClient: S3Client | null = null
function requiredEnv(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function optionalEnv(name: string): string | null {
const value = process.env[name]
return value ? value : null
}
function getR2Config() {
const accountId = requiredEnv("R2_ACCOUNT_ID")
const accessKeyId = requiredEnv("R2_ACCESS_KEY_ID")
const secretAccessKey = requiredEnv("R2_SECRET_ACCESS_KEY")
const bucket = requiredEnv("R2_BUCKET_NAME")
const publicBaseUrl = requiredEnv("R2_PUBLIC_BASE_URL")
return {
accountId,
accessKeyId,
secretAccessKey,
bucket,
publicBaseUrl: publicBaseUrl.replace(/\/+$/, ""),
}
}
function getR2Client(): S3Client {
if (cachedClient) return cachedClient
const { accountId, accessKeyId, secretAccessKey } = getR2Config()
cachedClient = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId,
secretAccessKey,
},
})
return cachedClient
}
function extensionFromMimeType(mimeType: string | null | undefined): string {
if (!mimeType) return ".jpg"
const normalized = mimeType.toLowerCase()
if (normalized.includes("png")) return ".png"
if (normalized.includes("webp")) return ".webp"
if (normalized.includes("gif")) return ".gif"
if (normalized.includes("avif")) return ".avif"
if (normalized.includes("jpeg") || normalized.includes("jpg")) return ".jpg"
return ".jpg"
}
function extensionFromHint(fileNameHint?: string): string {
if (!fileNameHint) return ""
const ext = path.extname(fileNameHint).toLowerCase()
if (!ext) return ""
if (!/^\.[a-z0-9]{1,8}$/.test(ext)) return ""
return ext
}
export async function uploadBufferToR2(options: UploadToR2Options): Promise<string> {
const client = getR2Client()
const { bucket, publicBaseUrl } = getR2Config()
const keyPrefix = (options.keyPrefix || "covers").replace(/^\/+|\/+$/g, "")
const ext = extensionFromHint(options.fileNameHint) || extensionFromMimeType(options.contentType)
const key = `${keyPrefix}/${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: options.buffer,
ContentType: options.contentType || "application/octet-stream",
})
)
return `${publicBaseUrl}/${key}`
}
export function getR2ObjectKeyFromUrl(url: string | null | undefined): string | null {
if (!url) return null
const publicBaseUrl = optionalEnv("R2_PUBLIC_BASE_URL")?.replace(/\/+$/, "")
if (!publicBaseUrl) return null
let baseUrl: URL
let fileUrl: URL
try {
baseUrl = new URL(publicBaseUrl)
fileUrl = new URL(url)
} catch {
return null
}
if (baseUrl.origin !== fileUrl.origin) return null
const basePath = baseUrl.pathname.replace(/\/+$/, "")
if (!fileUrl.pathname.startsWith(basePath)) return null
const relativePath = fileUrl.pathname.slice(basePath.length).replace(/^\/+/, "")
if (!relativePath) return null
return decodeURIComponent(relativePath)
}
export async function deleteR2ObjectByUrl(url: string | null | undefined): Promise<boolean> {
const key = getR2ObjectKeyFromUrl(url)
if (!key) return false
const client = getR2Client()
const { bucket } = getR2Config()
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
})
)
return true
}