refactor(media): use asset-centric storage key layout

This commit is contained in:
2026-02-12 18:41:01 +01:00
parent 86a8af25d8
commit 3e4f0b6c75
12 changed files with 218 additions and 27 deletions

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto"
import { hasPermission } from "@cms/content/rbac"
import { createMediaAsset } from "@cms/db"
@@ -57,6 +58,20 @@ function parseTags(formData: FormData): string[] {
.filter((item) => item.length > 0)
}
function deriveTitleFromFilename(fileName: string): string {
const trimmed = fileName.trim()
if (!trimmed) {
return "Untitled media"
}
const dotIndex = trimmed.lastIndexOf(".")
const base = dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed
const normalized = base.trim()
return normalized.length > 0 ? normalized : "Untitled media"
}
function isMimeAllowed(mediaType: string, mimeType: string): boolean {
const rule = ALLOWED_MIME_BY_TYPE[mediaType]
@@ -116,14 +131,9 @@ export async function POST(request: Request): Promise<Response> {
return badRequest("Invalid form payload.")
}
const title = parseTextField(formData, "title")
const type = parseTextField(formData, "type")
const fileEntry = formData.get("file")
if (!title) {
return badRequest("Title is required.")
}
if (!type) {
return badRequest("Type is required.")
}
@@ -146,13 +156,21 @@ export async function POST(request: Request): Promise<Response> {
return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`)
}
const title = parseTextField(formData, "title") || deriveTitleFromFilename(fileEntry.name)
const mediaAssetId = randomUUID()
const variant = "original"
const fileRole = "original"
try {
const stored = await storeUpload({
file: fileEntry,
mediaType: type,
assetId: mediaAssetId,
variant,
fileRole,
})
const created = await createMediaAsset({
id: mediaAssetId,
title,
type,
description: parseOptionalField(formData, "description"),
@@ -171,6 +189,7 @@ export async function POST(request: Request): Promise<Response> {
{
id: created.id,
provider: stored.provider,
warning: stored.fallbackReason,
notice: "Media uploaded successfully.",
},
{ status: 201 },

View File

@@ -1,6 +1,7 @@
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
import { AdminShell } from "@/components/admin-shell"
import { FlashQueryCleanup } from "@/components/media/flash-query-cleanup"
import { MediaUploadForm } from "@/components/media/media-upload-form"
import { resolveMediaStorageProvider } from "@/lib/media/storage"
import { requirePermissionForRoute } from "@/lib/route-guards"
@@ -34,7 +35,10 @@ export default async function MediaManagementPage({
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const uploadedVia = readFirstValue(resolvedSearchParams.uploadedVia)
const warning = readFirstValue(resolvedSearchParams.warning)
const activeStorageProvider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
const hasFlashQuery = Boolean(notice || error || warning || uploadedVia)
return (
<AdminShell
@@ -44,9 +48,18 @@ export default async function MediaManagementPage({
title="Media"
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
>
<FlashQueryCleanup enabled={hasFlashQuery} />
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
<div className="flex flex-wrap items-center gap-2">
<span>{notice}</span>
{uploadedVia ? (
<span className="rounded border border-emerald-300 bg-white px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-emerald-700">
Stored via: {uploadedVia}
</span>
) : null}
</div>
</section>
) : null}
@@ -56,6 +69,12 @@ export default async function MediaManagementPage({
</section>
) : null}
{warning ? (
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{warning}
</section>
) : null}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>

View File

@@ -0,0 +1,19 @@
"use client"
import { useEffect } from "react"
type FlashQueryCleanupProps = {
enabled: boolean
}
export function FlashQueryCleanup({ enabled }: FlashQueryCleanupProps) {
useEffect(() => {
if (!enabled) {
return
}
window.history.replaceState(window.history.state, "", "/media")
}, [enabled])
return null
}

View File

@@ -44,10 +44,15 @@ export function MediaUploadForm() {
const payload = (await response.json().catch(() => null)) as {
notice?: string
provider?: "s3" | "local"
warning?: string
} | null
const notice = payload?.notice ?? "Media uploaded."
window.location.href = `/media?notice=${encodeURIComponent(notice)}`
const provider = payload?.provider ?? "local"
const warning = payload?.warning
const warningQuery = warning ? `&warning=${encodeURIComponent(warning)}` : ""
window.location.href = `/media?notice=${encodeURIComponent(notice)}&uploadedVia=${encodeURIComponent(provider)}${warningQuery}`
} catch {
setError("Upload request failed. Please retry.")
} finally {
@@ -68,8 +73,7 @@ export function MediaUploadForm() {
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
minLength={1}
placeholder="Optional (defaults to file name)"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>

View File

@@ -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)

View File

@@ -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(

View 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",
)
})
})

View File

@@ -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("/")
}

View File

@@ -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,