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 { 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), }), ) }