From 7d9bc9dca9197e87cc590ad6b49837c5774fcd4f Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 19:15:26 +0100 Subject: [PATCH] feat(media): add admin media CRUD preview and storage cleanup --- TODO.md | 1 + .../src/app/api/media/file/[id]/route.ts | 120 +++++ apps/admin/src/app/media/[id]/page.tsx | 423 ++++++++++++++++++ apps/admin/src/app/media/page.tsx | 12 +- apps/admin/src/lib/media/local-storage.ts | 27 +- apps/admin/src/lib/media/s3-storage.ts | 20 +- apps/admin/src/lib/media/storage.ts | 30 +- packages/content/src/media.ts | 18 + packages/db/src/index.ts | 3 + packages/db/src/media-foundation.test.ts | 32 +- packages/db/src/media-foundation.ts | 23 + 11 files changed, 699 insertions(+), 10 deletions(-) create mode 100644 apps/admin/src/app/api/media/file/[id]/route.ts create mode 100644 apps/admin/src/app/media/[id]/page.tsx diff --git a/TODO.md b/TODO.md index ed9c34a..ff78d37 100644 --- a/TODO.md +++ b/TODO.md @@ -273,6 +273,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] MVP1 media upload pipeline started: admin `/api/media/upload` accepts metadata + file upload with permission checks, stores files via local adapter (`.data/media`), and persists upload metadata to `MediaAsset`. - [2026-02-12] Upload storage is now provider-based (`local` + `s3`) via `CMS_MEDIA_STORAGE_PROVIDER`; admin-side GUI toggle remains a later MVP item. - [2026-02-12] Media storage keys now use asset-centric layout (`tenant//asset///__.`) with DB-managed media taxonomy. +- [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions. ## How We Use This File diff --git a/apps/admin/src/app/api/media/file/[id]/route.ts b/apps/admin/src/app/api/media/file/[id]/route.ts new file mode 100644 index 0000000..f456a3a --- /dev/null +++ b/apps/admin/src/app/api/media/file/[id]/route.ts @@ -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 { + const baseDirectory = resolveLocalMediaBaseDirectory() + const outputPath = path.join(baseDirectory, storageKey) + + return readFile(outputPath) +} + +async function readFromS3Storage(storageKey: string): Promise { + 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 { + 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 }, + ) +} diff --git a/apps/admin/src/app/media/[id]/page.tsx b/apps/admin/src/app/media/[id]/page.tsx new file mode 100644 index 0000000..dd62a32 --- /dev/null +++ b/apps/admin/src/app/media/[id]/page.tsx @@ -0,0 +1,423 @@ +import { deleteMediaAsset, getMediaAssetById, updateMediaAsset } from "@cms/db" +import { Button } from "@cms/ui/button" +import Link from "next/link" +import { redirect } from "next/navigation" + +import { AdminShell } from "@/components/admin-shell" +import { deleteStoredMediaObject } from "@/lib/media/storage" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +type SearchParamsInput = Record + +type PageProps = { + params: Promise<{ id: string }> + searchParams: Promise +} + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readInputString(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function readNullableString(formData: FormData, field: string): string | null { + const value = readInputString(formData, field) + return value.length > 0 ? value : null +} + +function readNullableInt(formData: FormData, field: string): number | null { + const value = readInputString(formData, field) + + if (!value) { + return null + } + + const parsed = Number.parseInt(value, 10) + + if (!Number.isFinite(parsed)) { + return null + } + + return parsed +} + +function readTags(formData: FormData): string[] { + const raw = readInputString(formData, "tags") + + if (!raw) { + return [] + } + + return raw + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + +function redirectWithState(mediaAssetId: string, params: { notice?: string; error?: string }) { + const query = new URLSearchParams() + + if (params.notice) { + query.set("notice", params.notice) + } + + if (params.error) { + query.set("error", params.error) + } + + const value = query.toString() + redirect(value ? `/media/${mediaAssetId}?${value}` : `/media/${mediaAssetId}`) +} + +function toLocalDateTimeInputValue(date: Date): string { + const offset = date.getTimezoneOffset() * 60_000 + return new Date(date.getTime() - offset).toISOString().slice(0, 16) +} + +export default async function MediaAssetEditorPage({ params, searchParams }: PageProps) { + const role = await requirePermissionForRoute({ + nextPath: "/media", + permission: "media:read", + scope: "team", + }) + const resolvedParams = await params + const mediaAssetId = resolvedParams.id + + const [resolvedSearchParams, asset] = await Promise.all([ + searchParams, + getMediaAssetById(mediaAssetId), + ]) + + if (!asset) { + redirect("/media?error=Media+asset+not+found") + } + const mediaAsset = asset + + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) + const previewUrl = mediaAsset.storageKey ? `/api/media/file/${mediaAsset.id}` : null + const isImage = Boolean(mediaAsset.mimeType?.startsWith("image/")) + const isVideo = Boolean(mediaAsset.mimeType?.startsWith("video/")) + + async function updateMediaAssetAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/media", + permission: "media:write", + scope: "team", + }) + + try { + await updateMediaAsset({ + id: mediaAssetId, + title: readInputString(formData, "title"), + type: readInputString(formData, "type"), + description: readNullableString(formData, "description"), + altText: readNullableString(formData, "altText"), + source: readNullableString(formData, "source"), + copyright: readNullableString(formData, "copyright"), + author: readNullableString(formData, "author"), + tags: readTags(formData), + mimeType: readNullableString(formData, "mimeType"), + width: readNullableInt(formData, "width"), + height: readNullableInt(formData, "height"), + sizeBytes: readNullableInt(formData, "sizeBytes"), + isPublished: readInputString(formData, "isPublished") === "true", + }) + } catch { + redirectWithState(mediaAssetId, { + error: "Failed to update media asset. Validate values and try again.", + }) + } + + redirectWithState(mediaAssetId, { notice: "Media asset updated." }) + } + + async function deleteMediaAssetAction() { + "use server" + + await requirePermissionForRoute({ + nextPath: "/media", + permission: "media:write", + scope: "team", + }) + + try { + if (mediaAsset.storageKey) { + await deleteStoredMediaObject(mediaAsset.storageKey) + } + + await deleteMediaAsset(mediaAssetId) + } catch { + redirectWithState(mediaAssetId, { + error: + "Failed to delete media asset and file from storage. Check storage config and links.", + }) + } + + redirect("/media?notice=Media+asset+deleted") + } + + return ( + + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+

Preview

+

+ {mediaAsset.mimeType ? `MIME: ${mediaAsset.mimeType}` : "MIME: unknown"} +

+ +
+ {!previewUrl ? ( +

+ No stored file is linked for this media asset. +

+ ) : isImage ? ( + // biome-ignore lint/performance/noImgElement: Auth-protected media preview requires direct browser request with session cookies. + {mediaAsset.altText + ) : isVideo ? ( + // biome-ignore lint/a11y/useMediaCaption: Preview uses source assets without guaranteed caption tracks. +
+ + {previewUrl ? ( + + Open raw media file + + ) : null} +
+ +
+
+
+

{mediaAsset.title}

+

ID: {mediaAsset.id}

+
+ + Back to media list + +
+ +
+
+ + +
+ +