feat(media): add mvp1 upload pipeline baseline

This commit is contained in:
2026-02-12 11:57:39 +01:00
parent ad351ed73a
commit 5becba602c
8 changed files with 450 additions and 157 deletions

View File

@@ -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<string, AllowedRule> = {
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<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: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 },
)
}
}