256 lines
5.5 KiB
TypeScript
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),
|
|
})
|
|
)
|
|
}
|