From c6ebf3759a2a5bda1d66af5997ce8bf2e2cc8638 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 22:51:31 +0100 Subject: [PATCH] feat(media): add type-specific upload preset validation --- TODO.md | 3 +- apps/admin/src/app/api/media/upload/route.ts | 66 +++++--------------- packages/content/src/media.ts | 50 +++++++++++++++ 3 files changed, 68 insertions(+), 51 deletions(-) diff --git a/TODO.md b/TODO.md index acb4b4c..af522fd 100644 --- a/TODO.md +++ b/TODO.md @@ -144,7 +144,7 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls - [x] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility) - [x] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes) -- [ ] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules +- [x] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules - [ ] [P1] Users management (invite, roles, status) - [ ] [P1] Disable/ban user function and enforcement in auth/session checks - [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote) @@ -364,6 +364,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Portfolio grouping controls completed in admin `/portfolio`: galleries/albums/categories/tags now support visibility and sort-order management (create/update/delete), and public tag filters now respect visibility. - [2026-02-12] Artwork refinement baseline completed: admin `/portfolio` now captures/edits medium, dimensions, year, framing, availability, publish state, and optional price visibility (`priceAmountCents` + `priceCurrency`), with public artwork detail rendering visible prices only. - [2026-02-12] Artwork rendition management completed: admin `/portfolio` supports `thumbnail/card/full/retina/custom` slot assignment with dimensions and primary flag, plus per-artwork rendition listing and delete controls. +- [2026-02-12] Media type presets baseline completed in upload API: server-side validation now uses shared per-type rules (mime + max size) for `artwork/banner/promotion/video/gif/generic`, with optional env cap override via `CMS_MEDIA_UPLOAD_MAX_BYTES`. - [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries. - [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path). - [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`. diff --git a/apps/admin/src/app/api/media/upload/route.ts b/apps/admin/src/app/api/media/upload/route.ts index 47e9f6f..a1a4d82 100644 --- a/apps/admin/src/app/api/media/upload/route.ts +++ b/apps/admin/src/app/api/media/upload/route.ts @@ -1,4 +1,9 @@ import { randomUUID } from "node:crypto" +import { + getMediaUploadMaxBytes, + isMimeAllowedForMediaType, + mediaAssetTypeSchema, +} from "@cms/content" import { hasPermission } from "@cms/content/rbac" import { createMediaAsset } from "@cms/db" @@ -7,33 +12,7 @@ import { storeUpload } from "@/lib/media/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: "", - }, -} +const MAX_UPLOAD_BYTES_OVERRIDE = Number(process.env.CMS_MEDIA_UPLOAD_MAX_BYTES ?? 0) function parseTextField(formData: FormData, field: string): string { const value = formData.get(field) @@ -88,24 +67,6 @@ function deriveTitleFromFilename(fileName: string): string { return normalized.length > 0 ? normalized : "Untitled media" } -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( { @@ -147,12 +108,13 @@ export async function POST(request: Request): Promise { return badRequest("Invalid form payload.") } - const type = parseTextField(formData, "type") + const parsedType = mediaAssetTypeSchema.safeParse(parseTextField(formData, "type")) const fileEntry = formData.get("file") - if (!type) { + if (!parsedType.success) { return badRequest("Type is required.") } + const type = parsedType.data if (!(fileEntry instanceof File)) { return badRequest("File is required.") @@ -162,13 +124,17 @@ export async function POST(request: Request): Promise { return badRequest("File is empty.") } - if (fileEntry.size > MAX_UPLOAD_BYTES) { + const typeMaxBytes = getMediaUploadMaxBytes(type) + const effectiveMaxBytes = + MAX_UPLOAD_BYTES_OVERRIDE > 0 ? Math.min(MAX_UPLOAD_BYTES_OVERRIDE, typeMaxBytes) : typeMaxBytes + + if (fileEntry.size > effectiveMaxBytes) { return badRequest( - `File is too large. Maximum upload is ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)} MB.`, + `File is too large for ${type}. Maximum upload is ${Math.floor(effectiveMaxBytes / 1024 / 1024)} MB.`, ) } - if (!isMimeAllowed(type, fileEntry.type)) { + if (!isMimeAllowedForMediaType(type, fileEntry.type)) { return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`) } diff --git a/packages/content/src/media.ts b/packages/content/src/media.ts index ef4fae9..39eaad1 100644 --- a/packages/content/src/media.ts +++ b/packages/content/src/media.ts @@ -9,6 +9,56 @@ export const mediaAssetTypeSchema = z.enum([ "generic", ]) +export type MediaUploadRule = { + maxBytes: number + allowedMimePrefix?: string + allowedMimeExact?: string[] +} + +export const mediaUploadRulesByType: Record = { + artwork: { + maxBytes: 40 * 1024 * 1024, + allowedMimePrefix: "image/", + }, + banner: { + maxBytes: 20 * 1024 * 1024, + allowedMimePrefix: "image/", + }, + promotion: { + maxBytes: 20 * 1024 * 1024, + allowedMimePrefix: "image/", + }, + video: { + maxBytes: 250 * 1024 * 1024, + allowedMimePrefix: "video/", + }, + gif: { + maxBytes: 40 * 1024 * 1024, + allowedMimeExact: ["image/gif"], + }, + generic: { + maxBytes: 50 * 1024 * 1024, + }, +} + +export function isMimeAllowedForMediaType(type: MediaAssetType, mimeType: string): boolean { + const rule = mediaUploadRulesByType[type] + + if (rule.allowedMimeExact?.includes(mimeType)) { + return true + } + + if (rule.allowedMimePrefix) { + return mimeType.startsWith(rule.allowedMimePrefix) + } + + return true +} + +export function getMediaUploadMaxBytes(type: MediaAssetType): number { + return mediaUploadRulesByType[type].maxBytes +} + export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "retina", "custom"]) export const createMediaAssetInputSchema = z.object({