feat(media): add admin media CRUD preview and storage cleanup

This commit is contained in:
2026-02-12 19:15:26 +01:00
parent 3e4f0b6c75
commit 7d9bc9dca9
11 changed files with 699 additions and 10 deletions

View File

@@ -0,0 +1,120 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { hasPermission } from "@cms/content/rbac"
import { getMediaAssetById } from "@cms/db"
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
import { resolveLocalMediaBaseDirectory } from "@/lib/media/local-storage"
import { createS3Client, resolveS3Config } from "@/lib/media/s3-storage"
import { resolveMediaStorageProvider } from "@/lib/media/storage"
export const runtime = "nodejs"
type RouteContext = {
params: Promise<{ id: string }>
}
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()
}
function toBody(data: Uint8Array): BodyInit {
const bytes = new Uint8Array(data.byteLength)
bytes.set(data)
return bytes
}
export async function GET(request: Request, context: RouteContext): Promise<Response> {
const session = await auth.api
.getSession({
headers: request.headers,
})
.catch(() => null)
const role = resolveRoleFromAuthSession(session)
if (!role) {
return Response.json(
{
message: "Unauthorized",
},
{ status: 401 },
)
}
if (!hasPermission(role, "media:read", "team")) {
return Response.json(
{
message: "Missing permission: media:read",
},
{ status: 403 },
)
}
const { id } = await context.params
const asset = await getMediaAssetById(id)
if (!asset || !asset.storageKey) {
return Response.json(
{
message: "Media file not found",
},
{ status: 404 },
)
}
const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
const reads =
preferred === "s3"
? [
() => readFromS3Storage(asset.storageKey as string),
() => readFromLocalStorage(asset.storageKey as string),
]
: [
() => readFromLocalStorage(asset.storageKey as string),
() => readFromS3Storage(asset.storageKey as string),
]
for (const read of reads) {
try {
const data = await read()
return new Response(toBody(data), {
status: 200,
headers: {
"content-type": asset.mimeType || "application/octet-stream",
"cache-control": "private, max-age=0, no-store",
},
})
} catch {
// Try next backend.
}
}
return Response.json(
{
message: "Unable to read media file from configured storage backends",
},
{ status: 404 },
)
}