141 lines
3.7 KiB
TypeScript
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
|
|
}
|