Rocky_Mountain_Vending/lib/manuals-object-storage.ts

256 lines
5.5 KiB
TypeScript

import {
S3Client,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
} from "@aws-sdk/client-s3"
type AssetKind = "manuals" | "thumbnails"
type StorageConfig = {
endpoint: string
region: string
accessKeyId: string
secretAccessKey: string
forcePathStyle: boolean
disableSse: boolean
disableChecksums: boolean
manualsBucket: string
thumbnailsBucket: string
}
type StoredObject = {
body: Uint8Array
contentType?: string
contentLength?: number
lastModified?: Date
etag?: string
}
let cachedClient: S3Client | null = null
let cachedSignature: string | null = null
function readOptionalEnv(...keys: string[]) {
for (const key of keys) {
const value = String(process.env[key] ?? "").trim()
if (value) {
return value
}
}
return ""
}
function readBooleanEnv(keys: string[], fallback: boolean) {
for (const key of keys) {
const value = String(process.env[key] ?? "")
.trim()
.toLowerCase()
if (!value) {
continue
}
if (["1", "true", "yes", "on"].includes(value)) {
return true
}
if (["0", "false", "no", "off"].includes(value)) {
return false
}
}
return fallback
}
function getStorageConfig(): StorageConfig | null {
const endpoint = readOptionalEnv(
"MANUALS_STORAGE_ENDPOINT",
"S3_ENDPOINT_URL",
"CLOUDFLARE_R2_ENDPOINT"
)
const accessKeyId = readOptionalEnv(
"MANUALS_STORAGE_ACCESS_KEY_ID",
"CLOUDFLARE_R2_ACCESS_KEY_ID",
"AWS_ACCESS_KEY_ID",
"AWS_ACCESS_KEY"
)
const secretAccessKey = readOptionalEnv(
"MANUALS_STORAGE_SECRET_ACCESS_KEY",
"CLOUDFLARE_R2_SECRET_ACCESS_KEY",
"AWS_SECRET_ACCESS_KEY",
"AWS_SECRET_KEY"
)
const region =
readOptionalEnv("MANUALS_STORAGE_REGION", "AWS_REGION") || "us-east-1"
const manualsBucket = readOptionalEnv(
"MANUALS_STORAGE_MANUALS_BUCKET",
"R2_MANUALS_BUCKET"
)
const thumbnailsBucket = readOptionalEnv(
"MANUALS_STORAGE_THUMBNAILS_BUCKET",
"R2_THUMBNAILS_BUCKET"
)
if (
!endpoint ||
!accessKeyId ||
!secretAccessKey ||
!manualsBucket ||
!thumbnailsBucket
) {
return null
}
return {
endpoint,
region,
accessKeyId,
secretAccessKey,
forcePathStyle: readBooleanEnv(
["MANUALS_STORAGE_FORCE_PATH_STYLE", "AWS_S3_FORCE_PATH_STYLE"],
true
),
disableSse: readBooleanEnv(
["MANUALS_STORAGE_DISABLE_SSE", "AWS_S3_DISABLE_SSE"],
true
),
disableChecksums: readBooleanEnv(
["MANUALS_STORAGE_DISABLE_CHECKSUMS", "AWS_S3_DISABLE_CHECKSUMS"],
true
),
manualsBucket,
thumbnailsBucket,
}
}
function getBucketName(kind: AssetKind, config: StorageConfig) {
return kind === "manuals" ? config.manualsBucket : config.thumbnailsBucket
}
function getClient(config: StorageConfig) {
const signature = JSON.stringify(config)
if (!cachedClient || cachedSignature !== signature) {
cachedClient = new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
cachedSignature = signature
}
return cachedClient
}
function guessContentType(key: string) {
const lower = key.toLowerCase()
if (lower.endsWith(".pdf")) {
return "application/pdf"
}
if (lower.endsWith(".png")) {
return "image/png"
}
if (lower.endsWith(".webp")) {
return "image/webp"
}
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
return "image/jpeg"
}
return "application/octet-stream"
}
export function hasManualObjectStorageConfig() {
return getStorageConfig() !== null
}
export async function getManualAssetFromStorage(
kind: AssetKind,
key: string
): Promise<StoredObject | null> {
const config = getStorageConfig()
if (!config) {
return null
}
try {
const response = await getClient(config).send(
new GetObjectCommand({
Bucket: getBucketName(kind, config),
Key: key,
})
)
if (!response.Body) {
return null
}
return {
body: await response.Body.transformToByteArray(),
contentType: response.ContentType,
contentLength: response.ContentLength,
lastModified: response.LastModified,
etag: response.ETag,
}
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })
.$metadata?.httpStatusCode
if (statusCode === 404 || statusCode === 403) {
return null
}
throw error
}
}
export async function manualAssetExists(kind: AssetKind, key: string) {
const config = getStorageConfig()
if (!config) {
return false
}
try {
await getClient(config).send(
new HeadObjectCommand({
Bucket: getBucketName(kind, config),
Key: key,
})
)
return true
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })
.$metadata?.httpStatusCode
if (statusCode === 404) {
return false
}
throw error
}
}
export async function uploadManualAsset(
kind: AssetKind,
key: string,
body: Uint8Array,
contentType?: string
) {
const config = getStorageConfig()
if (!config) {
throw new Error("Manual object storage is not configured.")
}
await getClient(config).send(
new PutObjectCommand({
Bucket: getBucketName(kind, config),
Key: key,
Body: body,
ContentType: contentType || guessContentType(key),
})
)
}