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
-
+ Upload Media Asset
+
+ Files are currently stored via local adapter. S3/object storage is the next incremental
+ step.
+
+
Recent Media Assets
- MVP1 Foundation
+ MVP1 Upload Pipeline
@@ -240,6 +97,8 @@ export default async function MediaManagementPage({
| Title |
Type |
+ MIME |
+ Size |
Published |
Updated |
@@ -247,8 +106,8 @@ export default async function MediaManagementPage({
{assets.length === 0 ? (
- |
- No media assets yet. Upload workflows land in `todo/mvp1-media-upload-pipeline`.
+ |
+ No media assets yet. Upload your first asset above.
|
) : (
@@ -256,6 +115,12 @@ export default async function MediaManagementPage({
| {asset.title} |
{asset.type} |
+ {asset.mimeType ?? "-"} |
+
+ {typeof asset.sizeBytes === "number"
+ ? `${Math.max(1, Math.round(asset.sizeBytes / 1024))} KB`
+ : "-"}
+ |
{asset.isPublished ? "yes" : "no"} |
{asset.updatedAt.toLocaleDateString("en-US")}
diff --git a/apps/admin/src/components/media/media-upload-form.tsx b/apps/admin/src/components/media/media-upload-form.tsx
new file mode 100644
index 0000000..0cc0c17
--- /dev/null
+++ b/apps/admin/src/components/media/media-upload-form.tsx
@@ -0,0 +1,163 @@
+"use client"
+
+import { Button } from "@cms/ui/button"
+import { type FormEvent, useState } from "react"
+
+type MediaType = "artwork" | "banner" | "promotion" | "video" | "gif" | "generic"
+
+const ACCEPT_BY_TYPE: Record = {
+ artwork: "image/jpeg,image/png,image/webp,image/avif,image/gif",
+ banner: "image/jpeg,image/png,image/webp,image/avif",
+ promotion: "image/jpeg,image/png,image/webp,image/avif,image/gif,video/mp4,video/webm",
+ video: "video/mp4,video/webm,video/quicktime",
+ gif: "image/gif",
+ generic: "image/*,video/*",
+}
+
+export function MediaUploadForm() {
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+ const [mediaType, setMediaType] = useState("artwork")
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ setError(null)
+ setIsSubmitting(true)
+
+ try {
+ const response = await fetch("/api/media/upload", {
+ method: "POST",
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const payload = (await response.json().catch(() => null)) as {
+ message?: string
+ } | null
+
+ setError(payload?.message ?? "Upload failed. Please verify file and metadata.")
+ return
+ }
+
+ const payload = (await response.json().catch(() => null)) as {
+ notice?: string
+ } | null
+
+ const notice = payload?.notice ?? "Media uploaded."
+ window.location.href = `/media?notice=${encodeURIComponent(notice)}`
+ } catch {
+ setError("Upload request failed. Please retry.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/admin/src/lib/media/local-storage.ts b/apps/admin/src/lib/media/local-storage.ts
new file mode 100644
index 0000000..ad67f4e
--- /dev/null
+++ b/apps/admin/src/lib/media/local-storage.ts
@@ -0,0 +1,66 @@
+import { randomUUID } from "node:crypto"
+import { mkdir, writeFile } from "node:fs/promises"
+import path from "node:path"
+
+type StoreUploadParams = {
+ file: File
+ mediaType: string
+}
+
+type StoredUpload = {
+ storageKey: string
+}
+
+const FALLBACK_EXTENSION = "bin"
+
+function resolveBaseDirectory(): string {
+ const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim()
+
+ if (configured) {
+ return path.resolve(configured)
+ }
+
+ return path.resolve(process.cwd(), ".data", "media")
+}
+
+function normalizeSegment(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+}
+
+function extensionFromFilename(fileName: string): string {
+ const extension = path.extname(fileName).slice(1)
+
+ if (!extension) {
+ return FALLBACK_EXTENSION
+ }
+
+ const normalized = normalizeSegment(extension)
+
+ return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
+}
+
+function buildStorageKey(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)
+
+ return [normalizedType, year, month, `${randomUUID()}.${extension}`].join("/")
+}
+
+export async function storeUploadLocally(params: StoreUploadParams): Promise {
+ const storageKey = buildStorageKey(params.mediaType, params.file.name)
+ const baseDirectory = resolveBaseDirectory()
+ const outputPath = path.join(baseDirectory, storageKey)
+
+ await mkdir(path.dirname(outputPath), { recursive: true })
+
+ const bytes = new Uint8Array(await params.file.arrayBuffer())
+ await writeFile(outputPath, bytes)
+
+ return { storageKey }
+}
diff --git a/packages/content/src/media.ts b/packages/content/src/media.ts
index 9456de0..a020416 100644
--- a/packages/content/src/media.ts
+++ b/packages/content/src/media.ts
@@ -20,6 +20,12 @@ export const createMediaAssetInputSchema = z.object({
copyright: z.string().max(500).optional(),
author: z.string().max(180).optional(),
tags: z.array(z.string().min(1).max(100)).default([]),
+ storageKey: z.string().max(500).optional(),
+ mimeType: z.string().max(180).optional(),
+ sizeBytes: z.number().int().min(0).optional(),
+ width: z.number().int().positive().optional(),
+ height: z.number().int().positive().optional(),
+ isPublished: z.boolean().optional(),
})
export const createArtworkInputSchema = z.object({
|