From 5becba602c3aaefbe24ec71414f62b29a155d158 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 11:57:39 +0100 Subject: [PATCH] feat(media): add mvp1 upload pipeline baseline --- .env.example | 3 + .gitignore | 4 + TODO.md | 5 +- apps/admin/src/app/api/media/upload/route.ts | 185 ++++++++++++++++++ apps/admin/src/app/media/page.tsx | 175 ++--------------- .../components/media/media-upload-form.tsx | 163 +++++++++++++++ apps/admin/src/lib/media/local-storage.ts | 66 +++++++ packages/content/src/media.ts | 6 + 8 files changed, 450 insertions(+), 157 deletions(-) create mode 100644 apps/admin/src/app/api/media/upload/route.ts create mode 100644 apps/admin/src/components/media/media-upload-form.tsx create mode 100644 apps/admin/src/lib/media/local-storage.ts diff --git a/.env.example b/.env.example index 8f4e725..4f3563a 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ CMS_SUPPORT_EMAIL="support@cms.local" CMS_SUPPORT_PASSWORD="change-me-support-password" CMS_SUPPORT_NAME="Technical Support" CMS_SUPPORT_LOGIN_KEY="support-access-change-me" +CMS_MEDIA_UPLOAD_MAX_BYTES="26214400" +# Optional: override local media storage directory for admin upload adapter. +# CMS_MEDIA_LOCAL_STORAGE_DIR="/absolute/path/to/media-storage" NEXT_PUBLIC_APP_VERSION="0.1.0-dev" NEXT_PUBLIC_GIT_SHA="local" # Optional dev bypass role for admin middleware. Leave empty to require auth login. diff --git a/.gitignore b/.gitignore index 499c639..60032e5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ packages/db/prisma/generated/ # misc .DS_Store + +# local media storage +.data/ +apps/admin/.data/ diff --git a/TODO.md b/TODO.md index 945a79b..e3be7f1 100644 --- a/TODO.md +++ b/TODO.md @@ -120,7 +120,7 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] `todo/mvp1-media-foundation`: media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots -- [ ] [P1] `todo/mvp1-media-upload-pipeline`: +- [~] [P1] `todo/mvp1-media-upload-pipeline`: S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI - [ ] [P1] `todo/mvp1-pages-navigation-builder`: page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds) @@ -147,7 +147,7 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [P1] Page management (create/edit/publish/unpublish/schedule) - [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards) - [ ] [P1] Navigation management (menus, nested items, order, visibility) -- [ ] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif) +- [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif) - [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context) - [ ] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls - [ ] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility) @@ -270,6 +270,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-11] MVP1 media foundation started: portfolio domain models (`MediaAsset`, `Artwork`, galleries/albums/categories/tags, rendition links) plus initial admin `/media` and `/portfolio` data views. - [2026-02-11] `prisma migrate dev --name media_foundation` can fail when DB endpoint is unreachable; apply this named migration once `DATABASE_URL` host is reachable again. - [2026-02-11] MVP1 media foundation now includes baseline create/link workflows in admin (`/media`, `/portfolio`), seeded sample portfolio entities, and schema/service test coverage. +- [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`. ## How We Use This File diff --git a/apps/admin/src/app/api/media/upload/route.ts b/apps/admin/src/app/api/media/upload/route.ts new file mode 100644 index 0000000..ad30f89 --- /dev/null +++ b/apps/admin/src/app/api/media/upload/route.ts @@ -0,0 +1,185 @@ +import { hasPermission } from "@cms/content/rbac" +import { createMediaAsset } from "@cms/db" + +import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server" +import { storeUploadLocally } from "@/lib/media/local-storage" + +export const runtime = "nodejs" + +const MAX_UPLOAD_BYTES = Number(process.env.CMS_MEDIA_UPLOAD_MAX_BYTES ?? 25 * 1024 * 1024) + +type AllowedRule = { + mimePrefix?: string + mimeExact?: string[] +} + +const ALLOWED_MIME_BY_TYPE: Record = { + artwork: { + mimePrefix: "image/", + }, + banner: { + mimePrefix: "image/", + }, + promotion: { + mimePrefix: "image/", + }, + video: { + mimePrefix: "video/", + }, + gif: { + mimeExact: ["image/gif"], + }, + generic: { + mimePrefix: "", + }, +} + +function parseTextField(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function parseOptionalField(formData: FormData, field: string): string | undefined { + const value = parseTextField(formData, field) + return value.length > 0 ? value : undefined +} + +function parseTags(formData: FormData): string[] { + const value = parseTextField(formData, "tags") + + if (!value) { + return [] + } + + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + +function isMimeAllowed(mediaType: string, mimeType: string): boolean { + const rule = ALLOWED_MIME_BY_TYPE[mediaType] + + if (!rule) { + return false + } + + if (rule.mimeExact?.includes(mimeType)) { + return true + } + + if (rule.mimePrefix === "") { + return true + } + + return rule.mimePrefix ? mimeType.startsWith(rule.mimePrefix) : false +} + +function badRequest(message: string): Response { + return Response.json( + { + message, + }, + { status: 400 }, + ) +} + +export async function POST(request: Request): 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:write", "team")) { + return Response.json( + { + message: "Missing permission: media:write", + }, + { status: 403 }, + ) + } + + const formData = await request.formData().catch(() => null) + + if (!formData) { + 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.") + } + + if (!(fileEntry instanceof File)) { + return badRequest("File is required.") + } + + if (fileEntry.size === 0) { + return badRequest("File is empty.") + } + + if (fileEntry.size > MAX_UPLOAD_BYTES) { + return badRequest( + `File is too large. Maximum upload is ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)} MB.`, + ) + } + + if (!isMimeAllowed(type, fileEntry.type)) { + return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`) + } + + try { + const stored = await storeUploadLocally({ + file: fileEntry, + mediaType: type, + }) + + const created = await createMediaAsset({ + title, + type, + description: parseOptionalField(formData, "description"), + altText: parseOptionalField(formData, "altText"), + source: parseOptionalField(formData, "source"), + copyright: parseOptionalField(formData, "copyright"), + author: parseOptionalField(formData, "author"), + tags: parseTags(formData), + storageKey: stored.storageKey, + mimeType: fileEntry.type || undefined, + sizeBytes: fileEntry.size, + isPublished: parseTextField(formData, "isPublished") === "true", + }) + + return Response.json( + { + id: created.id, + notice: "Media uploaded successfully.", + }, + { status: 201 }, + ) + } catch { + return Response.json( + { + message: "Upload failed. Please try again.", + }, + { status: 500 }, + ) + } +} diff --git a/apps/admin/src/app/media/page.tsx b/apps/admin/src/app/media/page.tsx index b189fbb..3e79dbe 100644 --- a/apps/admin/src/app/media/page.tsx +++ b/apps/admin/src/app/media/page.tsx @@ -1,9 +1,7 @@ -import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } from "@cms/db" -import { Button } from "@cms/ui/button" -import { revalidatePath } from "next/cache" -import { redirect } from "next/navigation" +import { getMediaFoundationSummary, listMediaAssets } from "@cms/db" import { AdminShell } from "@/components/admin-shell" +import { MediaUploadForm } from "@/components/media/media-upload-form" import { requirePermissionForRoute } from "@/lib/route-guards" export const dynamic = "force-dynamic" @@ -18,75 +16,6 @@ function readFirstValue(value: string | string[] | undefined): string | null { return value ?? null } -function readField(formData: FormData, field: string): string { - const value = formData.get(field) - return typeof value === "string" ? value.trim() : "" -} - -function readOptionalField(formData: FormData, field: string): string | undefined { - const value = readField(formData, field) - return value.length > 0 ? value : undefined -} - -function readTags(formData: FormData, field: string): string[] { - const raw = readField(formData, field) - - if (!raw) { - return [] - } - - return raw - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0) -} - -function redirectWithState(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?${value}` : "/media") -} - -async function createMediaAssetAction(formData: FormData) { - "use server" - - await requirePermissionForRoute({ - nextPath: "/media", - permission: "media:write", - scope: "team", - }) - - try { - await createMediaAsset({ - title: readField(formData, "title"), - type: readField(formData, "type"), - description: readOptionalField(formData, "description"), - altText: readOptionalField(formData, "altText"), - source: readOptionalField(formData, "source"), - copyright: readOptionalField(formData, "copyright"), - author: readOptionalField(formData, "author"), - tags: readTags(formData, "tags"), - }) - } catch { - redirectWithState({ - error: "Failed to create media asset. Validate required fields and try again.", - }) - } - - revalidatePath("/media") - revalidatePath("/portfolio") - redirectWithState({ notice: "Media asset created." }) -} - export default async function MediaManagementPage({ searchParams, }: { @@ -141,97 +70,25 @@ export default async function MediaManagementPage({

{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "} - categories{" · "} - {summary.tags} tags + categories · {summary.tags} tags

-

Create Media Asset

-
-
- - -
-