refactor(media): use asset-centric storage key layout
This commit is contained in:
@@ -5,7 +5,10 @@ import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
||||
|
||||
type StoreLocalUploadParams = {
|
||||
file: File
|
||||
mediaType: string
|
||||
tenantId: string
|
||||
assetId: string
|
||||
fileRole: string
|
||||
variant: string
|
||||
}
|
||||
|
||||
type StoredUpload = {
|
||||
@@ -23,7 +26,13 @@ function resolveBaseDirectory(): string {
|
||||
}
|
||||
|
||||
export async function storeUploadLocally(params: StoreLocalUploadParams): Promise<StoredUpload> {
|
||||
const storageKey = buildMediaStorageKey(params.mediaType, params.file.name)
|
||||
const storageKey = buildMediaStorageKey({
|
||||
tenantId: params.tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
fileName: params.file.name,
|
||||
})
|
||||
const baseDirectory = resolveBaseDirectory()
|
||||
const outputPath = path.join(baseDirectory, storageKey)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
||||
|
||||
type StoreS3UploadParams = {
|
||||
file: File
|
||||
mediaType: string
|
||||
tenantId: string
|
||||
assetId: string
|
||||
fileRole: string
|
||||
variant: string
|
||||
}
|
||||
|
||||
type StoredUpload = {
|
||||
@@ -62,7 +65,13 @@ function createS3Client(config: S3Config): S3Client {
|
||||
export async function storeUploadToS3(params: StoreS3UploadParams): Promise<StoredUpload> {
|
||||
const config = resolveS3Config()
|
||||
const client = createS3Client(config)
|
||||
const storageKey = buildMediaStorageKey(params.mediaType, params.file.name)
|
||||
const storageKey = buildMediaStorageKey({
|
||||
tenantId: params.tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
fileName: params.file.name,
|
||||
})
|
||||
const payload = new Uint8Array(await params.file.arrayBuffer())
|
||||
|
||||
await client.send(
|
||||
|
||||
19
apps/admin/src/lib/media/storage-key.test.ts
Normal file
19
apps/admin/src/lib/media/storage-key.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
||||
|
||||
describe("buildMediaStorageKey", () => {
|
||||
it("builds asset-centric key with fileRole and variant", () => {
|
||||
const key = buildMediaStorageKey({
|
||||
tenantId: "default",
|
||||
assetId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
fileRole: "original",
|
||||
variant: "thumb",
|
||||
fileName: "My File.PNG",
|
||||
})
|
||||
|
||||
expect(key).toBe(
|
||||
"tenant/default/asset/550e8400-e29b-41d4-a716-446655440000/original/550e8400-e29b-41d4-a716-446655440000__thumb.png",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,15 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
|
||||
const FALLBACK_EXTENSION = "bin"
|
||||
const DEFAULT_VARIANT = "original"
|
||||
|
||||
type BuildMediaStorageKeyParams = {
|
||||
tenantId: string
|
||||
assetId: string
|
||||
fileRole: string
|
||||
variant?: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
function normalizeSegment(value: string): string {
|
||||
return value
|
||||
@@ -22,12 +30,20 @@ function extensionFromFilename(fileName: string): string {
|
||||
return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
|
||||
}
|
||||
|
||||
export function buildMediaStorageKey(mediaType: string, fileName: string): string {
|
||||
const now = new Date()
|
||||
const year = String(now.getUTCFullYear())
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0")
|
||||
const normalizedType = normalizeSegment(mediaType) || "generic"
|
||||
const extension = extensionFromFilename(fileName)
|
||||
export function buildMediaStorageKey(params: BuildMediaStorageKeyParams): string {
|
||||
const normalizedTenantId = normalizeSegment(params.tenantId) || "default"
|
||||
const normalizedAssetId = normalizeSegment(params.assetId)
|
||||
const normalizedFileRole = normalizeSegment(params.fileRole) || "original"
|
||||
const normalizedVariant = normalizeSegment(params.variant ?? DEFAULT_VARIANT) || DEFAULT_VARIANT
|
||||
const extension = extensionFromFilename(params.fileName)
|
||||
const fileName = `${normalizedAssetId}__${normalizedVariant}.${extension}`
|
||||
|
||||
return [normalizedType, year, month, `${randomUUID()}.${extension}`].join("/")
|
||||
return [
|
||||
"tenant",
|
||||
normalizedTenantId,
|
||||
"asset",
|
||||
normalizedAssetId,
|
||||
normalizedFileRole,
|
||||
fileName,
|
||||
].join("/")
|
||||
}
|
||||
|
||||
@@ -5,12 +5,65 @@ export type MediaStorageProvider = "local" | "s3"
|
||||
|
||||
type StoreUploadParams = {
|
||||
file: File
|
||||
mediaType: string
|
||||
assetId: string
|
||||
variant: string
|
||||
fileRole: string
|
||||
}
|
||||
|
||||
type StoredUpload = {
|
||||
storageKey: string
|
||||
provider: MediaStorageProvider
|
||||
fallbackReason?: string
|
||||
}
|
||||
|
||||
type S3LikeError = {
|
||||
name?: unknown
|
||||
message?: unknown
|
||||
Code?: unknown
|
||||
code?: unknown
|
||||
$metadata?: {
|
||||
httpStatusCode?: unknown
|
||||
requestId?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTenantId(): string {
|
||||
return process.env.CMS_MEDIA_STORAGE_TENANT_ID?.trim() || "default"
|
||||
}
|
||||
|
||||
function describeS3Error(error: unknown): string {
|
||||
if (!error || typeof error !== "object") {
|
||||
return "Unknown S3 error"
|
||||
}
|
||||
|
||||
const err = error as S3LikeError
|
||||
const details: string[] = []
|
||||
|
||||
if (typeof err.name === "string" && err.name.length > 0) {
|
||||
details.push(`name=${err.name}`)
|
||||
}
|
||||
|
||||
if (typeof err.message === "string" && err.message.length > 0) {
|
||||
details.push(`message=${err.message}`)
|
||||
}
|
||||
|
||||
if (typeof err.Code === "string" && err.Code.length > 0) {
|
||||
details.push(`code=${err.Code}`)
|
||||
} else if (typeof err.code === "string" && err.code.length > 0) {
|
||||
details.push(`code=${err.code}`)
|
||||
}
|
||||
|
||||
const status = err.$metadata?.httpStatusCode
|
||||
if (typeof status === "number") {
|
||||
details.push(`status=${status}`)
|
||||
}
|
||||
|
||||
const requestId = err.$metadata?.requestId
|
||||
if (typeof requestId === "string" && requestId.length > 0) {
|
||||
details.push(`requestId=${requestId}`)
|
||||
}
|
||||
|
||||
return details.length > 0 ? details.join(", ") : "Unknown S3 error"
|
||||
}
|
||||
|
||||
export function resolveMediaStorageProvider(raw: string | undefined): MediaStorageProvider {
|
||||
@@ -23,24 +76,45 @@ export function resolveMediaStorageProvider(raw: string | undefined): MediaStora
|
||||
|
||||
export async function storeUpload(params: StoreUploadParams): Promise<StoredUpload> {
|
||||
const provider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||
const tenantId = resolveTenantId()
|
||||
|
||||
if (provider === "s3") {
|
||||
try {
|
||||
const stored = await storeUploadToS3(params)
|
||||
const stored = await storeUploadToS3({
|
||||
file: params.file,
|
||||
tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
})
|
||||
return {
|
||||
...stored,
|
||||
provider,
|
||||
}
|
||||
} catch {
|
||||
const fallbackStored = await storeUploadLocally(params)
|
||||
} catch (error) {
|
||||
const detail = describeS3Error(error)
|
||||
const fallbackStored = await storeUploadLocally({
|
||||
file: params.file,
|
||||
tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
})
|
||||
return {
|
||||
...fallbackStored,
|
||||
provider: "local",
|
||||
fallbackReason: `S3 upload failed; file stored locally instead. ${detail}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stored = await storeUploadLocally(params)
|
||||
const stored = await storeUploadLocally({
|
||||
file: params.file,
|
||||
tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
})
|
||||
|
||||
return {
|
||||
...stored,
|
||||
|
||||
Reference in New Issue
Block a user