feat(web): add public portfolio rendering and media streaming
This commit is contained in:
114
apps/web/src/lib/media/storage-read.ts
Normal file
114
apps/web/src/lib/media/storage-read.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"
|
||||
|
||||
export type MediaStorageProvider = "local" | "s3"
|
||||
|
||||
type S3Config = {
|
||||
bucket: string
|
||||
region: string
|
||||
endpoint?: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
forcePathStyle?: boolean
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined): boolean {
|
||||
return value?.toLowerCase() === "true"
|
||||
}
|
||||
|
||||
export function resolveMediaStorageProvider(raw: string | undefined): MediaStorageProvider {
|
||||
if (raw?.toLowerCase() === "local") {
|
||||
return "local"
|
||||
}
|
||||
|
||||
return "s3"
|
||||
}
|
||||
|
||||
function resolveS3Config(): S3Config {
|
||||
const bucket = process.env.CMS_MEDIA_S3_BUCKET?.trim()
|
||||
const region = process.env.CMS_MEDIA_S3_REGION?.trim()
|
||||
const accessKeyId = process.env.CMS_MEDIA_S3_ACCESS_KEY_ID?.trim()
|
||||
const secretAccessKey = process.env.CMS_MEDIA_S3_SECRET_ACCESS_KEY?.trim()
|
||||
const endpoint = process.env.CMS_MEDIA_S3_ENDPOINT?.trim() || undefined
|
||||
|
||||
if (!bucket || !region || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
"S3 storage selected but required env vars are missing: CMS_MEDIA_S3_BUCKET, CMS_MEDIA_S3_REGION, CMS_MEDIA_S3_ACCESS_KEY_ID, CMS_MEDIA_S3_SECRET_ACCESS_KEY",
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
bucket,
|
||||
region,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
forcePathStyle: parseBoolean(process.env.CMS_MEDIA_S3_FORCE_PATH_STYLE),
|
||||
}
|
||||
}
|
||||
|
||||
function createS3Client(config: S3Config): S3Client {
|
||||
return new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resolveLocalMediaBaseDirectory(): string {
|
||||
const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim()
|
||||
|
||||
if (configured) {
|
||||
return path.resolve(configured)
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), ".data", "media")
|
||||
}
|
||||
|
||||
async function readFromLocalStorage(storageKey: string): Promise<Uint8Array> {
|
||||
const baseDirectory = resolveLocalMediaBaseDirectory()
|
||||
const outputPath = path.join(baseDirectory, storageKey)
|
||||
|
||||
return readFile(outputPath)
|
||||
}
|
||||
|
||||
async function readFromS3Storage(storageKey: string): Promise<Uint8Array> {
|
||||
const config = resolveS3Config()
|
||||
const client = createS3Client(config)
|
||||
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: storageKey,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!response.Body) {
|
||||
throw new Error("S3 object body is empty")
|
||||
}
|
||||
|
||||
return response.Body.transformToByteArray()
|
||||
}
|
||||
|
||||
export async function readMediaStorageObject(storageKey: string): Promise<Uint8Array> {
|
||||
const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||
const reads =
|
||||
preferred === "s3"
|
||||
? [() => readFromS3Storage(storageKey), () => readFromLocalStorage(storageKey)]
|
||||
: [() => readFromLocalStorage(storageKey), () => readFromS3Storage(storageKey)]
|
||||
|
||||
for (const read of reads) {
|
||||
try {
|
||||
return await read()
|
||||
} catch {
|
||||
// Try next backend.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to read media file from configured storage backends")
|
||||
}
|
||||
Reference in New Issue
Block a user