From 09445e825dd149464f0ef703b16c0810aad6e764 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 16:30:05 +0300 Subject: [PATCH 01/26] feat(core): add matchesMimeAllowlist helper for per-field MIME validation Implements foundational utilities for checking whether a MIME type matches an allowlist of exact types and type/* prefixes. Includes EXTENSION_TO_MIME mapping and expandExtensionShorthand() for converting file extensions to MIME types. - matchesMimeAllowlist(): checks if a MIME type matches any entry in an allowlist (exact match or prefix) - expandExtensionShorthand(): converts .ext notation or MIME strings to canonical MIME types - EXTENSION_TO_MIME: comprehensive mapping of common file extensions to MIME types All tests passing (8 tests). Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/media/mime.ts | 49 +++++++++++++++++++ packages/core/tests/unit/media/mime.test.ts | 54 +++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 packages/core/src/media/mime.ts create mode 100644 packages/core/tests/unit/media/mime.test.ts diff --git a/packages/core/src/media/mime.ts b/packages/core/src/media/mime.ts new file mode 100644 index 000000000..24b6a496a --- /dev/null +++ b/packages/core/src/media/mime.ts @@ -0,0 +1,49 @@ +export function matchesMimeAllowlist(mime: string, allowList: readonly string[]): boolean { + for (const entry of allowList) { + if (!entry || !entry.includes("/")) continue; + if (entry.endsWith("/")) { + if (mime.startsWith(entry)) return true; + } else if (mime === entry) { + return true; + } + } + return false; +} + +export const EXTENSION_TO_MIME: Readonly> = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".txt": "text/plain", + ".rtf": "application/rtf", + ".vtt": "text/vtt", + ".srt": "application/x-subrip", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +export function expandExtensionShorthand(entry: string): string | null { + const trimmed = entry.trim(); + if (!trimmed) return null; + if (trimmed.includes("/")) return trimmed; + if (trimmed.startsWith(".")) { + return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null; + } + return null; +} diff --git a/packages/core/tests/unit/media/mime.test.ts b/packages/core/tests/unit/media/mime.test.ts new file mode 100644 index 000000000..78ff7d548 --- /dev/null +++ b/packages/core/tests/unit/media/mime.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; + +import { matchesMimeAllowlist, expandExtensionShorthand } from "../../../src/media/mime.js"; + +describe("matchesMimeAllowlist", () => { + it("matches exact MIME types", () => { + expect(matchesMimeAllowlist("image/png", ["image/png"])).toBe(true); + expect(matchesMimeAllowlist("image/jpeg", ["image/png"])).toBe(false); + }); + + it("matches type/ prefix entries", () => { + expect(matchesMimeAllowlist("image/png", ["image/"])).toBe(true); + expect(matchesMimeAllowlist("image/anything", ["image/"])).toBe(true); + expect(matchesMimeAllowlist("video/mp4", ["image/"])).toBe(false); + }); + + it("matches against a mixed list", () => { + const list = ["application/pdf", "image/", "application/zip"]; + expect(matchesMimeAllowlist("image/jpeg", list)).toBe(true); + expect(matchesMimeAllowlist("application/pdf", list)).toBe(true); + expect(matchesMimeAllowlist("application/zip", list)).toBe(true); + expect(matchesMimeAllowlist("video/mp4", list)).toBe(false); + }); + + it("returns false for an empty list", () => { + expect(matchesMimeAllowlist("image/png", [])).toBe(false); + }); + + it("ignores malformed entries (no slash) without throwing", () => { + expect(matchesMimeAllowlist("image/png", ["image"])).toBe(false); + expect(matchesMimeAllowlist("image/png", [""])).toBe(false); + }); +}); + +describe("expandExtensionShorthand", () => { + it("passes through an already-MIME entry", () => { + expect(expandExtensionShorthand("image/png")).toBe("image/png"); + expect(expandExtensionShorthand("image/")).toBe("image/"); + }); + + it("expands known dot-extensions", () => { + expect(expandExtensionShorthand(".pdf")).toBe("application/pdf"); + expect(expandExtensionShorthand(".PDF")).toBe("application/pdf"); + expect(expandExtensionShorthand(".docx")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + }); + + it("returns null for unknown shorthand", () => { + expect(expandExtensionShorthand(".xyz")).toBeNull(); + expect(expandExtensionShorthand("notamime")).toBeNull(); + expect(expandExtensionShorthand("")).toBeNull(); + }); +}); From 5ef22bd23831fb42f56a5c039120508aed08a2c6 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 16:39:46 +0300 Subject: [PATCH 02/26] feat(core): add allowedMimeTypes to FieldValidation --- packages/core/src/schema/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a607ce3c5..6049ed925 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -131,6 +131,7 @@ export interface FieldValidation { subFields?: RepeaterSubField[]; // For repeater fields minItems?: number; // For repeater fields maxItems?: number; // For repeater fields + allowedMimeTypes?: string[]; } /** From 5e1ab0cbdcd900e97de34ef753a130d96cf11eb6 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 16:45:40 +0300 Subject: [PATCH 03/26] feat(core): file/image builders write allowedMimeTypes into validation; export file Adds validation.allowedMimeTypes to FieldDefinition, and updates the file() and image() builders to populate it from allowedTypes options. Removes the legacy ui.allowedTypes placement. Re-exports file from the package entry point. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/fields/file.ts | 13 ++++---- packages/core/src/fields/image.ts | 23 ++++++------- packages/core/src/fields/types.ts | 3 ++ packages/core/src/index.ts | 2 +- .../unit/fields/file-image-builders.test.ts | 33 +++++++++++++++++++ 5 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 packages/core/tests/unit/fields/file-image-builders.test.ts diff --git a/packages/core/src/fields/file.ts b/packages/core/src/fields/file.ts index c736f3a5d..60af5c7e2 100644 --- a/packages/core/src/fields/file.ts +++ b/packages/core/src/fields/file.ts @@ -5,13 +5,10 @@ import type { FieldDefinition, FieldUIHints, FileValue } from "./types.js"; export interface FileOptions { required?: boolean; maxSize?: number; // In bytes - allowedTypes?: string[]; // MIME types + allowedTypes?: string[]; // MIME types — exact (image/png) or prefix (image/) helpText?: string; } -/** - * File field - file upload - */ export function file(options: FileOptions = {}): FieldDefinition { const fileObjSchema = z.object({ id: z.string(), @@ -21,21 +18,25 @@ export function file(options: FileOptions = {}): FieldDefinition { size: z.number(), }); - // Optional vs required const schema: z.ZodTypeAny = options.required ? fileObjSchema : fileObjSchema.optional(); const ui: FieldUIHints = { widget: "file", helpText: options.helpText, maxSize: options.maxSize, - allowedTypes: options.allowedTypes, }; + const validation = + options.allowedTypes && options.allowedTypes.length > 0 + ? { allowedMimeTypes: [...options.allowedTypes] } + : undefined; + return { type: "file", columnType: "TEXT", schema, options, ui, + validation, }; } diff --git a/packages/core/src/fields/image.ts b/packages/core/src/fields/image.ts index 41e20b489..58329ef58 100644 --- a/packages/core/src/fields/image.ts +++ b/packages/core/src/fields/image.ts @@ -2,9 +2,6 @@ import { z } from "astro/zod"; import type { FieldDefinition, ImageValue } from "./types.js"; -/** - * Image field schema - */ const imageSchema = z.object({ id: z.string(), src: z.string(), @@ -13,22 +10,26 @@ const imageSchema = z.object({ height: z.number().optional(), }); -/** - * Image field - * References media items from the media library - */ -export function image(options?: { +export interface ImageOptions { required?: boolean; maxSize?: number; // in bytes - allowedTypes?: string[]; // MIME types -}): FieldDefinition { + allowedTypes?: string[]; // MIME types — exact or prefix +} + +export function image(options: ImageOptions = {}): FieldDefinition { + const validation = + options.allowedTypes && options.allowedTypes.length > 0 + ? { allowedMimeTypes: [...options.allowedTypes] } + : undefined; + return { type: "image", columnType: "TEXT", - schema: options?.required === false ? imageSchema.optional() : imageSchema, + schema: options.required === false ? imageSchema.optional() : imageSchema, options, ui: { widget: "image", }, + validation, }; } diff --git a/packages/core/src/fields/types.ts b/packages/core/src/fields/types.ts index e08485080..8620ddde7 100644 --- a/packages/core/src/fields/types.ts +++ b/packages/core/src/fields/types.ts @@ -1,5 +1,7 @@ import type { z } from "astro/zod"; +import type { FieldValidation } from "../schema/types.js"; + /** * SQLite column types that map from field types */ @@ -19,6 +21,7 @@ export interface FieldDefinition<_T = unknown> { schema: z.ZodTypeAny; options?: unknown; ui?: FieldUIHints; + validation?: FieldValidation; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db6626c2b..dfa8ce9c7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,7 @@ export type { export type { MediaItem, CreateMediaInput } from "./database/repositories/media.js"; // Fields -export { portableText, image, reference } from "./fields/index.js"; +export { portableText, image, file, reference } from "./fields/index.js"; export { normalizeMediaValue } from "./media/normalize.js"; export { generatePlaceholder } from "./media/placeholder.js"; export type { PlaceholderData } from "./media/placeholder.js"; diff --git a/packages/core/tests/unit/fields/file-image-builders.test.ts b/packages/core/tests/unit/fields/file-image-builders.test.ts new file mode 100644 index 000000000..6a0020736 --- /dev/null +++ b/packages/core/tests/unit/fields/file-image-builders.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { file } from "../../../src/fields/file.js"; +import { image } from "../../../src/fields/image.js"; + +describe("file builder", () => { + it("copies allowedTypes into validation.allowedMimeTypes", () => { + const def = file({ allowedTypes: ["application/pdf", "application/zip"] }); + expect(def.validation?.allowedMimeTypes).toEqual(["application/pdf", "application/zip"]); + }); + + it("does not write ui.allowedTypes (legacy inert location)", () => { + const def = file({ allowedTypes: ["application/pdf"] }); + expect(def.ui?.allowedTypes).toBeUndefined(); + }); + + it("omits allowedMimeTypes when allowedTypes is not provided", () => { + const def = file({}); + expect(def.validation?.allowedMimeTypes).toBeUndefined(); + }); +}); + +describe("image builder", () => { + it("copies allowedTypes into validation.allowedMimeTypes", () => { + const def = image({ allowedTypes: ["image/png", "image/jpeg"] }); + expect(def.validation?.allowedMimeTypes).toEqual(["image/png", "image/jpeg"]); + }); + + it("omits allowedMimeTypes when allowedTypes is not provided", () => { + const def = image(); + expect(def.validation?.allowedMimeTypes).toBeUndefined(); + }); +}); From 4b1063470635f99da5129cf779b70d47e19393ac Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 17:03:31 +0300 Subject: [PATCH 04/26] feat(core): MediaRepository.findMany accepts an array of MIME prefixes/exacts Widens `FindManyMediaOptions.mimeType` and `count()` from `string` to `string | readonly string[]` so callers can filter by multiple MIME types in a single query. Strings ending with "/" are LIKE prefix matches; others are exact equality. Adds integration tests via `describeEachDialect`. Co-Authored-By: Claude Sonnet 4.6 --- .../core/src/database/repositories/media.ts | 45 ++++++++++++--- .../database/media-mime-filter.test.ts | 56 +++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 packages/core/tests/integration/database/media-mime-filter.test.ts diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index e88180ca3..9f895127a 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -10,6 +10,16 @@ function escapeLike(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); } +/** + * Normalize a mimeType filter (string or array) into a clean string[]. + * Entries that are empty strings are dropped. + */ +function normalizeMimeFilter(input?: string | readonly string[]): string[] { + if (!input) return []; + const arr = Array.isArray(input) ? input : [input]; + return arr.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + export type MediaStatus = "pending" | "ready" | "failed"; export interface MediaItem { @@ -49,7 +59,8 @@ export interface CreateMediaInput { export interface FindManyMediaOptions { limit?: number; cursor?: string; - mimeType?: string; // Filter by mime type prefix, e.g., "image/" + /** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */ + mimeType?: string | readonly string[]; status?: MediaStatus | "all"; // Filter by status, defaults to "ready" } @@ -215,9 +226,17 @@ export class MediaRepository { ); } - if (options.mimeType) { - const pattern = `${escapeLike(options.mimeType)}%`; - query = query.where(sql`mime_type LIKE ${pattern} ESCAPE '\\'`); + const mimeFilters = normalizeMimeFilter(options.mimeType); + if (mimeFilters.length > 0) { + query = query.where((eb) => + eb.or( + mimeFilters.map((entry) => + entry.endsWith("/") + ? sql`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'` + : eb("mime_type", "=", entry), + ), + ), + ); } // Default to only showing ready items @@ -276,12 +295,20 @@ export class MediaRepository { /** * Count media items */ - async count(mimeType?: string): Promise { - let query = this.db.selectFrom("media").select((eb) => eb.fn.count("id").as("count")); + async count(mimeType?: string | readonly string[]): Promise { + const filters = normalizeMimeFilter(mimeType); + let query = this.db.selectFrom("media").select((eb) => eb.fn.count("id").as("count")); - if (mimeType) { - const pattern = `${escapeLike(mimeType)}%`; - query = query.where(sql`mime_type LIKE ${pattern} ESCAPE '\\'`); + if (filters.length > 0) { + query = query.where((eb) => + eb.or( + filters.map((entry) => + entry.endsWith("/") + ? sql`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'` + : eb("mime_type", "=", entry), + ), + ), + ); } const result = await query.executeTakeFirst(); diff --git a/packages/core/tests/integration/database/media-mime-filter.test.ts b/packages/core/tests/integration/database/media-mime-filter.test.ts new file mode 100644 index 000000000..be84c3e55 --- /dev/null +++ b/packages/core/tests/integration/database/media-mime-filter.test.ts @@ -0,0 +1,56 @@ +import { it, expect, beforeEach, afterEach } from "vitest"; + +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("MediaRepository.findMany mimeType filter", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function seedMedia() { + const repo = new MediaRepository(ctx.db); + await repo.create({ filename: "a.png", mimeType: "image/png", storageKey: "a.png" }); + await repo.create({ filename: "b.jpg", mimeType: "image/jpeg", storageKey: "b.jpg" }); + await repo.create({ filename: "c.pdf", mimeType: "application/pdf", storageKey: "c.pdf" }); + await repo.create({ filename: "d.zip", mimeType: "application/zip", storageKey: "d.zip" }); + } + + it("filters by a single MIME prefix (existing behavior)", async () => { + await seedMedia(); + const repo = new MediaRepository(ctx.db); + const result = await repo.findMany({ mimeType: "image/" }); + expect(result.items.map((i) => i.mimeType).toSorted()).toEqual(["image/jpeg", "image/png"]); + }); + + it("filters by an array of MIME entries (prefix + exact)", async () => { + await seedMedia(); + const repo = new MediaRepository(ctx.db); + const result = await repo.findMany({ + mimeType: ["image/", "application/pdf"], + }); + expect(result.items.map((i) => i.mimeType).toSorted()).toEqual([ + "application/pdf", + "image/jpeg", + "image/png", + ]); + }); + + it("returns an empty list when none match", async () => { + await seedMedia(); + const repo = new MediaRepository(ctx.db); + const result = await repo.findMany({ mimeType: ["video/"] }); + expect(result.items).toEqual([]); + }); +}); From 1d591c72755bb44b646a34f829f5c92ed8e309f5 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 17:19:17 +0300 Subject: [PATCH 05/26] feat(core): media list endpoints accept comma-separated mimeType Adds a `mimeTypeFilter` Zod helper that normalises a comma-separated string (URL query param) or a string array (JSON body / programmatic use) into `string[]`. Threads the widened type through the schema, handler, runtime wrapper, and EmDashHandlers facade. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/api/handlers/media.ts | 2 +- packages/core/src/api/schemas/media.ts | 16 +++++++-- packages/core/src/astro/types.ts | 2 +- packages/core/src/emdash-runtime.ts | 6 +++- .../tests/unit/api/media-list-route.test.ts | 36 +++++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 packages/core/tests/unit/api/media-list-route.test.ts diff --git a/packages/core/src/api/handlers/media.ts b/packages/core/src/api/handlers/media.ts index e0f19b88b..89e6c6fa6 100644 --- a/packages/core/src/api/handlers/media.ts +++ b/packages/core/src/api/handlers/media.ts @@ -26,7 +26,7 @@ export async function handleMediaList( params: { cursor?: string; limit?: number; - mimeType?: string; + mimeType?: string | readonly string[]; }, ): Promise> { try { diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index b81968b02..640ddc425 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -6,9 +6,21 @@ import { cursorPaginationQuery } from "./common.js"; // Media: Input schemas // --------------------------------------------------------------------------- +/** + * Accepts a comma-separated string (from URL query params) or an array of + * strings (from JSON body or programmatic use) and normalises to string[]. + */ +const mimeTypeFilter = z + .union([z.string(), z.array(z.string())]) + .transform((v) => { + const arr = Array.isArray(v) ? v : v.split(","); + return arr.map((s) => s.trim()).filter((s) => s.length > 0); + }) + .optional(); + export const mediaListQuery = cursorPaginationQuery .extend({ - mimeType: z.string().optional(), + mimeType: mimeTypeFilter, }) .meta({ id: "MediaListQuery" }); @@ -59,7 +71,7 @@ export const mediaConfirmBody = z export const mediaProviderListQuery = cursorPaginationQuery .extend({ query: z.string().optional(), - mimeType: z.string().optional(), + mimeType: mimeTypeFilter, }) .meta({ id: "MediaProviderListQuery" }); diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index c15497b96..31d657710 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -292,7 +292,7 @@ export interface EmDashHandlers { handleMediaList: (params: { cursor?: string; limit?: number; - mimeType?: string; + mimeType?: string | readonly string[]; }) => Promise; handleMediaGet: (id: string) => Promise; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 4a216e776..66e53d6b3 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1980,7 +1980,11 @@ export class EmDashRuntime { // Media Handlers // ========================================================================= - async handleMediaList(params: { cursor?: string; limit?: number; mimeType?: string }) { + async handleMediaList(params: { + cursor?: string; + limit?: number; + mimeType?: string | readonly string[]; + }) { return handleMediaList(this.db, params); } diff --git a/packages/core/tests/unit/api/media-list-route.test.ts b/packages/core/tests/unit/api/media-list-route.test.ts new file mode 100644 index 000000000..90b63f0da --- /dev/null +++ b/packages/core/tests/unit/api/media-list-route.test.ts @@ -0,0 +1,36 @@ +import { it, expect, describe, beforeEach, afterEach } from "vitest"; + +import { handleMediaList } from "../../../src/api/handlers/media.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import { + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describe("handleMediaList multi-MIME", () => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect("sqlite"); + const repo = new MediaRepository(ctx.db); + await repo.create({ filename: "a.png", mimeType: "image/png", storageKey: "a.png" }); + await repo.create({ filename: "b.pdf", mimeType: "application/pdf", storageKey: "b.pdf" }); + await repo.create({ filename: "c.zip", mimeType: "application/zip", storageKey: "c.zip" }); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("accepts an array of MIME entries", async () => { + const result = await handleMediaList(ctx.db, { + mimeType: ["image/", "application/pdf"], + }); + if (!result.success) throw new Error("expected success"); + expect(result.data.items.map((i) => i.mimeType).toSorted()).toEqual([ + "application/pdf", + "image/png", + ]); + }); +}); From 5734d978e1cf339720844266d2e07e0dbe951fac Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 17:35:34 +0300 Subject: [PATCH 06/26] feat(core): widen POST /api/media allowlist when fieldId resolves to a file/image field Reads an optional `fieldId` from the multipart form data and, when that field exists and has a non-empty `allowedMimeTypes` list in its validation JSON, uses that list instead of the global allowlist. Falls back to the global allowlist (image/, video/, audio/, application/pdf) when no fieldId is provided or the field carries no custom list. Shared helpers extracted to `api/handlers/media-allowlist.ts` for use by Task 7. Co-Authored-By: Claude Sonnet 4.6 --- .../core/src/api/handlers/media-allowlist.ts | 46 ++++ packages/core/src/astro/routes/api/media.ts | 14 +- .../astro/media-upload-widening.test.ts | 234 ++++++++++++++++++ 3 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/api/handlers/media-allowlist.ts create mode 100644 packages/core/tests/integration/astro/media-upload-widening.test.ts diff --git a/packages/core/src/api/handlers/media-allowlist.ts b/packages/core/src/api/handlers/media-allowlist.ts new file mode 100644 index 000000000..1685ef6be --- /dev/null +++ b/packages/core/src/api/handlers/media-allowlist.ts @@ -0,0 +1,46 @@ +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; + +/** + * MIME types allowed for upload by default (when no field-specific list + * overrides this). Entries ending with "/" are prefix-matched (e.g. + * "image/" matches "image/jpeg", "image/png", etc.). + */ +export const GLOBAL_UPLOAD_ALLOWLIST: readonly string[] = [ + "image/", + "video/", + "audio/", + "application/pdf", +]; + +/** + * Resolve the MIME allowlist for a specific field. + * + * Returns the field's `allowedMimeTypes` list when the field exists, is of + * type "file" or "image", and has a non-empty list configured. Returns null + * in all other cases — callers should fall back to GLOBAL_UPLOAD_ALLOWLIST. + */ +export async function resolveFieldAllowlist( + db: Kysely, + fieldId: string, +): Promise { + const row = await db + .selectFrom("_emdash_fields") + .select(["type", "validation"]) + .where("id", "=", fieldId) + .executeTakeFirst(); + + if (!row) return null; + if (row.type !== "file" && row.type !== "image") return null; + if (!row.validation) return null; + + try { + const parsed = JSON.parse(row.validation) as { allowedMimeTypes?: string[] }; + const list = parsed.allowedMimeTypes; + if (!list || list.length === 0) return null; + return list; + } catch { + return null; + } +} diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index f94705ecb..9ad11bc2c 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -12,9 +12,11 @@ import { ulid } from "ulidx"; import { requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js"; +import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js"; import { isParseError, parseQuery } from "#api/parse.js"; import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js"; import { MediaRepository } from "#db/repositories/media.js"; +import { matchesMimeAllowlist } from "#media/mime.js"; import { generatePlaceholder } from "#media/placeholder.js"; import { computeContentHash } from "#utils/hash.js"; @@ -106,9 +108,15 @@ export const POST: APIRoute = async ({ request, locals }) => { return apiError("NO_FILE", "No file provided", 400); } - // Validate file type - const allowedTypes = ["image/", "video/", "audio/", "application/pdf"]; - if (!allowedTypes.some((type) => file.type.startsWith(type))) { + // Validate file type — widen the allowlist when a field-specific list is configured + const fieldIdEntry = formData.get("fieldId"); + const fieldId = + typeof fieldIdEntry === "string" && fieldIdEntry.length > 0 ? fieldIdEntry : null; + + const fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId) : null; + const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST]; + + if (!matchesMimeAllowlist(file.type, allowlist)) { return apiError("INVALID_TYPE", "File type not allowed", 400); } diff --git a/packages/core/tests/integration/astro/media-upload-widening.test.ts b/packages/core/tests/integration/astro/media-upload-widening.test.ts new file mode 100644 index 000000000..bf312db52 --- /dev/null +++ b/packages/core/tests/integration/astro/media-upload-widening.test.ts @@ -0,0 +1,234 @@ +/** + * Upload-widening tests for POST /_emdash/api/media. + * + * When a `fieldId` is included in the multipart body and that field has + * a custom `allowedMimeTypes` list in its validation JSON, the route must + * use that list instead of the global allowlist. This enables per-field + * MIME restrictions such as "PDF only" or "zip files allowed here". + * + * Test cases: + * 1. zip accepted when fieldId points to a zip-allowing file field + * 2. zip rejected when fieldId is omitted (global allowlist applies) + * 3. zip rejected when fieldId points to an image-only field + */ + +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { POST as postMedia } from "../../../src/astro/routes/api/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +// --------------------------------------------------------------------------- +// Minimal in-memory storage stub +// --------------------------------------------------------------------------- + +interface StorageEntry { + body: Uint8Array; + contentType: string; +} + +function createMemoryStorage(): { + store: Map; + storage: { + upload: (opts: { key: string; body: Uint8Array; contentType: string }) => Promise; + download: (key: string) => Promise; + delete: (key: string) => Promise; + exists: (key: string) => Promise; + list: () => Promise; + getSignedUploadUrl: () => Promise; + }; +} { + const store = new Map(); + const storage = { + async upload(opts: { key: string; body: Uint8Array; contentType: string }) { + store.set(opts.key, { body: opts.body, contentType: opts.contentType }); + }, + async download(key: string) { + return store.get(key)?.body ?? null; + }, + async delete(key: string) { + store.delete(key); + }, + async exists(key: string) { + return store.has(key); + }, + async list() { + return [...store.keys()]; + }, + async getSignedUploadUrl() { + return "http://localhost/signed"; + }, + }; + return { store, storage }; +} + +// --------------------------------------------------------------------------- +// Context builder +// --------------------------------------------------------------------------- + +function buildContext(opts: { + db: Kysely; + request: Request; + storage: ReturnType["storage"]; +}): APIContext { + return { + params: {}, + url: new URL(opts.request.url), + request: opts.request, + locals: { + emdash: { + db: opts.db, + config: {}, + storage: opts.storage, + handleMediaList: async () => ({ success: true as const, data: { items: [] } }), + handleMediaCreate: async (input: { + filename: string; + mimeType: string; + size: number; + storageKey: string; + contentHash: string; + authorId?: string; + width?: number; + height?: number; + blurhash?: string; + dominantColor?: string; + }) => ({ + success: true as const, + data: { + item: { + id: "test-id", + filename: input.filename, + mimeType: input.mimeType, + size: input.size, + storageKey: input.storageKey, + contentHash: input.contentHash, + width: input.width ?? null, + height: input.height ?? null, + blurhash: input.blurhash ?? null, + dominantColor: input.dominantColor ?? null, + authorId: input.authorId ?? null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }, + }), + }, + user: { + id: "user-1", + email: "test@example.com", + name: "Test User", + // RoleLevel 50 = ADMIN (satisfies media:upload which requires CONTRIBUTOR = 20) + role: 50 as const, + }, + }, + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub for tests + } as unknown as APIContext; +} + +function buildUploadRequest(opts: { file: File; fieldId?: string }): Request { + const formData = new FormData(); + formData.append("file", opts.file); + if (opts.fieldId) { + formData.append("fieldId", opts.fieldId); + } + return new Request("http://localhost/_emdash/api/media", { + method: "POST", + headers: { + "X-EmDash-Request": "1", + }, + body: formData, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("POST /media — upload widening via fieldId", () => { + let db: Kysely; + let zipFieldId: string; + let imageFieldId: string; + + beforeEach(async () => { + db = await setupTestDatabase(); + const registry = new SchemaRegistry(db); + + // Create a collection with two fields: + // - attachments: file field that allows zip files + // - thumbnail: image field (image/* only, no zips) + await registry.createCollection({ + slug: "article", + label: "Articles", + labelSingular: "Article", + }); + + const zipField = await registry.createField("article", { + slug: "attachment", + label: "Attachment", + type: "file", + validation: { allowedMimeTypes: ["application/zip"] }, + }); + zipFieldId = zipField.id; + + const imageField = await registry.createField("article", { + slug: "thumbnail", + label: "Thumbnail", + type: "image", + validation: { allowedMimeTypes: ["image/"] }, + }); + imageFieldId = imageField.id; + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("accepts a zip upload when fieldId resolves to a zip-allowing field", async () => { + const { storage } = createMemoryStorage(); + const zipFile = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], "archive.zip", { + type: "application/zip", + }); + + const req = buildUploadRequest({ file: zipFile, fieldId: zipFieldId }); + const res = await postMedia(buildContext({ db, request: req, storage })); + + expect(res.status).toBe(201); + const body = (await res.json()) as { + data?: { item?: { mimeType: string } }; + error?: { code: string }; + }; + expect(body.error).toBeUndefined(); + expect(body.data?.item?.mimeType).toBe("application/zip"); + }); + + it("rejects a zip upload when no fieldId is provided (global allowlist)", async () => { + const { storage } = createMemoryStorage(); + const zipFile = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], "archive.zip", { + type: "application/zip", + }); + + const req = buildUploadRequest({ file: zipFile }); + const res = await postMedia(buildContext({ db, request: req, storage })); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: { code: string } }; + expect(body.error?.code).toBe("INVALID_TYPE"); + }); + + it("rejects a zip upload when fieldId points to an image-only field", async () => { + const { storage } = createMemoryStorage(); + const zipFile = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], "archive.zip", { + type: "application/zip", + }); + + const req = buildUploadRequest({ file: zipFile, fieldId: imageFieldId }); + const res = await postMedia(buildContext({ db, request: req, storage })); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: { code: string } }; + expect(body.error?.code).toBe("INVALID_TYPE"); + }); +}); From 79031163e7a00f8ce3be3a403d988c918ef60077 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 17:49:37 +0300 Subject: [PATCH 07/26] feat(core): widen upload-url allowlist via fieldId Adds `fieldId` to `mediaUploadUrlBody` schema and wires field-aware MIME allowlist resolution into `POST /_emdash/api/media/upload-url`, mirroring the widening already applied to the direct-upload route. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/api/schemas/media.ts | 1 + .../src/astro/routes/api/media/upload-url.ts | 12 +- .../astro/media-upload-widening.test.ts | 177 ++++++++++++++++++ 3 files changed, 187 insertions(+), 3 deletions(-) diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 640ddc425..e23f643c2 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -56,6 +56,7 @@ export function mediaUploadUrlBody(maxSize: number) { .positive() .max(maxSize, `File size must not exceed ${formatFileSize(maxSize)}`), contentHash: z.string().optional(), + fieldId: z.string().optional(), }) .meta({ id: "MediaUploadUrlBody" }); } diff --git a/packages/core/src/astro/routes/api/media/upload-url.ts b/packages/core/src/astro/routes/api/media/upload-url.ts index c7ab7b424..dd7c884a6 100644 --- a/packages/core/src/astro/routes/api/media/upload-url.ts +++ b/packages/core/src/astro/routes/api/media/upload-url.ts @@ -15,8 +15,10 @@ import { ulid } from "ulidx"; import { requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; +import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js"; import { isParseError, parseBody } from "#api/parse.js"; import { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from "#api/schemas.js"; +import { matchesMimeAllowlist } from "#media/mime.js"; export const prerender = false; @@ -70,9 +72,13 @@ export const POST: APIRoute = async ({ request, locals }) => { const body = await parseBody(request, mediaUploadUrlBody(maxSize)); if (isParseError(body)) return body; - // Validate content type - const allowedTypes = ["image/", "video/", "audio/", "application/pdf"]; - if (!allowedTypes.some((type) => body.contentType.startsWith(type))) { + // Validate content type (field-aware widening) + const fieldAllowlist = body.fieldId + ? await resolveFieldAllowlist(emdash.db, body.fieldId) + : null; + const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST]; + + if (!matchesMimeAllowlist(body.contentType, allowlist)) { return apiError("INVALID_TYPE", "File type not allowed", 400); } diff --git a/packages/core/tests/integration/astro/media-upload-widening.test.ts b/packages/core/tests/integration/astro/media-upload-widening.test.ts index bf312db52..8a3b37f18 100644 --- a/packages/core/tests/integration/astro/media-upload-widening.test.ts +++ b/packages/core/tests/integration/astro/media-upload-widening.test.ts @@ -17,6 +17,7 @@ import type { Kysely } from "kysely"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { POST as postMedia } from "../../../src/astro/routes/api/media.js"; +import { POST as postUploadUrl } from "../../../src/astro/routes/api/media/upload-url.js"; import type { Database } from "../../../src/database/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; @@ -232,3 +233,179 @@ describe("POST /media — upload widening via fieldId", () => { expect(body.error?.code).toBe("INVALID_TYPE"); }); }); + +// --------------------------------------------------------------------------- +// upload-url storage stub (returns a proper SignedUploadUrl object) +// --------------------------------------------------------------------------- + +function createSignedUrlStorage(): ReturnType["storage"] & { + getSignedUploadUrl: (opts: { + key: string; + contentType: string; + size: number; + expiresIn: number; + }) => Promise<{ url: string; method: "PUT"; headers: Record; expiresAt: string }>; +} { + const base = createMemoryStorage().storage; + return { + ...base, + async getSignedUploadUrl(opts: { + key: string; + contentType: string; + size: number; + expiresIn: number; + }) { + return { + url: `http://storage.example.com/${opts.key}`, + method: "PUT" as const, + headers: { "Content-Type": opts.contentType }, + expiresAt: new Date(Date.now() + opts.expiresIn * 1000).toISOString(), + }; + }, + }; +} + +function buildUploadUrlContext(opts: { + db: Kysely; + request: Request; + storage: ReturnType; +}): APIContext { + return { + params: {}, + url: new URL(opts.request.url), + request: opts.request, + locals: { + emdash: { + db: opts.db, + config: {}, + storage: opts.storage, + }, + user: { + id: "user-1", + email: "test@example.com", + name: "Test User", + // RoleLevel 50 = ADMIN (satisfies media:upload which requires CONTRIBUTOR = 20) + role: 50 as const, + }, + }, + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub for tests + } as unknown as APIContext; +} + +function buildUploadUrlRequest(opts: { + contentType: string; + filename: string; + size: number; + fieldId?: string; +}): Request { + const body: Record = { + filename: opts.filename, + contentType: opts.contentType, + size: opts.size, + }; + if (opts.fieldId) { + body.fieldId = opts.fieldId; + } + return new Request("http://localhost/_emdash/api/media/upload-url", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-EmDash-Request": "1", + }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// upload-url widening tests +// --------------------------------------------------------------------------- + +describe("POST /media/upload-url — upload widening via fieldId", () => { + let db: Kysely; + let zipFieldId: string; + let imageFieldId: string; + + beforeEach(async () => { + db = await setupTestDatabase(); + const registry = new SchemaRegistry(db); + + await registry.createCollection({ + slug: "article", + label: "Articles", + labelSingular: "Article", + }); + + const zipField = await registry.createField("article", { + slug: "attachment", + label: "Attachment", + type: "file", + validation: { allowedMimeTypes: ["application/zip"] }, + }); + zipFieldId = zipField.id; + + const imageField = await registry.createField("article", { + slug: "thumbnail", + label: "Thumbnail", + type: "image", + validation: { allowedMimeTypes: ["image/"] }, + }); + imageFieldId = imageField.id; + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("accepts a zip when fieldId resolves to a zip-allowing field (upload-url route)", async () => { + const storage = createSignedUrlStorage(); + const req = buildUploadUrlRequest({ + filename: "archive.zip", + contentType: "application/zip", + size: 1024, + fieldId: zipFieldId, + }); + const res = await postUploadUrl(buildUploadUrlContext({ db, request: req, storage })); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data?: { uploadUrl?: string }; + error?: { code: string }; + }; + expect(body.error).toBeUndefined(); + // uploadUrl is the signed URL returned by storage (contains a ULID-based key, not the filename) + expect(typeof body.data?.uploadUrl).toBe("string"); + expect(body.data?.uploadUrl).toMatch(/^http/); + }); + + it("rejects zip without fieldId (upload-url route)", async () => { + const storage = createSignedUrlStorage(); + const req = buildUploadUrlRequest({ + filename: "archive.zip", + contentType: "application/zip", + size: 1024, + }); + const res = await postUploadUrl(buildUploadUrlContext({ db, request: req, storage })); + + // 400 = rejected by MIME allowlist; 501 = storage doesn't support signed URLs + expect([400, 501]).toContain(res.status); + if (res.status === 400) { + const body = (await res.json()) as { error?: { code: string } }; + expect(body.error?.code).toBe("INVALID_TYPE"); + } + }); + + it("rejects zip when fieldId points to an image-only field (upload-url route)", async () => { + const storage = createSignedUrlStorage(); + const req = buildUploadUrlRequest({ + filename: "archive.zip", + contentType: "application/zip", + size: 1024, + fieldId: imageFieldId, + }); + const res = await postUploadUrl(buildUploadUrlContext({ db, request: req, storage })); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: { code: string } }; + expect(body.error?.code).toBe("INVALID_TYPE"); + }); +}); From 017254353b8486aaf7c8769942b1e42b7bc3ab28 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 18:10:45 +0300 Subject: [PATCH 08/26] feat(core): validate file/image fields against allowedMimeTypes on content save Adds save-side MIME validation via validateMediaFields. On create and update, any file/image field with an allowedMimeTypes constraint is checked against the actual MIME type of the referenced media (looked up from the media table for local refs, or taken from the value's mimeType property for external providers). Saves with disallowed types are rejected with INVALID_MIME_FOR_FIELD. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/upset-cougars-sniff.md | 5 + packages/core/src/api/handlers/content.ts | 11 ++ .../src/api/handlers/validate-media-fields.ts | 129 ++++++++++++++ .../content/media-field-validation.test.ts | 168 ++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 .changeset/upset-cougars-sniff.md create mode 100644 packages/core/src/api/handlers/validate-media-fields.ts create mode 100644 packages/core/tests/integration/content/media-field-validation.test.ts diff --git a/.changeset/upset-cougars-sniff.md b/.changeset/upset-cougars-sniff.md new file mode 100644 index 000000000..c94f981e6 --- /dev/null +++ b/.changeset/upset-cougars-sniff.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds save-side MIME validation for `file` and `image` fields with `allowedMimeTypes` constraints. Content creates and updates now reject media references whose MIME type is not in the field's allowlist, returning `INVALID_MIME_FOR_FIELD`. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 71fe4504b..7fd13ed8e 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -27,6 +27,7 @@ import { invalidateRedirectCache } from "../../redirects/cache.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; +import { validateMediaFields } from "./validate-media-fields.js"; /** * Narrow a caught error to one carrying a structured `apiError` discriminant. @@ -444,6 +445,10 @@ export async function handleContentCreate( }; } + // Validate file/image fields against their allowedMimeTypes constraints + const mimeCheck = await validateMediaFields(db, collection, body.data); + if (!mimeCheck.success) return mimeCheck; + // Wrap content + SEO writes in a transaction for atomicity const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); @@ -591,6 +596,12 @@ export async function handleContentUpdate( }; } + // Validate file/image fields against their allowedMimeTypes constraints + if (body.data) { + const mimeCheck = await validateMediaFields(db, collection, body.data); + if (!mimeCheck.success) return mimeCheck; + } + const repo = new ContentRepository(db); // Resolve slug → ID if needed diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts new file mode 100644 index 000000000..4d1b2add9 --- /dev/null +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -0,0 +1,129 @@ +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; +import { matchesMimeAllowlist } from "../../media/mime.js"; +import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; +import type { ApiResult } from "../types.js"; + +interface FieldRow { + slug: string; + type: string; + allowedMimeTypes: string[]; +} + +interface MediaRefValue { + id?: unknown; + provider?: unknown; + mimeType?: unknown; +} + +function asMediaRef(value: unknown): MediaRefValue | null { + if (value === null || value === undefined) return null; + if (typeof value !== "object") return null; + return value as MediaRefValue; +} + +async function loadMediaFieldsForCollection( + db: Kysely, + collectionSlug: string, +): Promise { + const collection = await db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", collectionSlug) + .executeTakeFirst(); + if (!collection) return []; + + const rows = await db + .selectFrom("_emdash_fields") + .select(["slug", "type", "validation"]) + .where("collection_id", "=", collection.id) + .where((eb) => eb.or([eb("type", "=", "file"), eb("type", "=", "image")])) + .execute(); + + const out: FieldRow[] = []; + for (const row of rows) { + if (!row.validation) continue; + try { + const parsed = JSON.parse(row.validation) as { allowedMimeTypes?: string[] }; + const list = parsed.allowedMimeTypes; + if (!list || list.length === 0) continue; + out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list }); + } catch { + // Malformed validation JSON — skip + } + } + return out; +} + +export async function validateMediaFields( + db: Kysely, + collectionSlug: string, + data: Record, +): Promise> { + const fields = await loadMediaFieldsForCollection(db, collectionSlug); + if (fields.length === 0) return { success: true, data: true }; + + // Collect local media ids that need a MIME lookup + const localIds = new Set(); + for (const field of fields) { + const ref = asMediaRef(data[field.slug]); + if (!ref) continue; + const provider = typeof ref.provider === "string" ? ref.provider : "local"; + if (provider === "local" && typeof ref.id === "string") { + localIds.add(ref.id); + } + } + + // Batch-load local media MIMEs + const idList = [...localIds]; + const mimeById = new Map(); + if (idList.length > 0) { + for (const batch of chunks(idList, SQL_BATCH_SIZE)) { + const rows = await db + .selectFrom("media") + .select(["id", "mime_type"]) + .where("id", "in", batch) + .execute(); + for (const r of rows) mimeById.set(r.id, r.mime_type); + } + } + + for (const field of fields) { + const value = data[field.slug]; + if (value === null || value === undefined) continue; + const ref = asMediaRef(value); + if (!ref) continue; + + const provider = typeof ref.provider === "string" ? ref.provider : "local"; + let mime: string | undefined; + if (provider === "local") { + if (typeof ref.id !== "string") continue; + mime = mimeById.get(ref.id); + } else { + if (typeof ref.mimeType === "string") mime = ref.mimeType; + } + + if (!mime) { + return { + success: false, + error: { + code: "INVALID_MIME_FOR_FIELD", + message: `Field '${field.slug}' references media with unknown MIME type`, + }, + }; + } + + if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) { + return { + success: false, + error: { + code: "INVALID_MIME_FOR_FIELD", + message: `Field '${field.slug}' does not accept ${mime}`, + }, + }; + } + } + + return { success: true, data: true }; +} diff --git a/packages/core/tests/integration/content/media-field-validation.test.ts b/packages/core/tests/integration/content/media-field-validation.test.ts new file mode 100644 index 000000000..64fd28bc6 --- /dev/null +++ b/packages/core/tests/integration/content/media-field-validation.test.ts @@ -0,0 +1,168 @@ +import { ulid } from "ulidx"; +import { it, expect, describe, beforeEach, afterEach } from "vitest"; + +import { handleContentCreate, handleContentUpdate } from "../../../src/api/handlers/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describe("save-side media-field MIME validation", () => { + let ctx: DialectTestContext; + let pdfMediaId: string; + let zipMediaId: string; + + beforeEach(async () => { + ctx = await setupForDialect("sqlite"); + + // Create a posts collection with title and attachment fields + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ + slug: "posts", + label: "Posts", + labelSingular: "Post", + }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + }); + + // Look up the collection id + const collection = await ctx.db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", "posts") + .executeTakeFirstOrThrow(); + + // Add a `file` field to posts that allows only PDFs + await ctx.db + .insertInto("_emdash_fields") + .values({ + id: ulid(), + collection_id: collection.id, + slug: "attachment", + label: "Attachment", + type: "file", + column_type: "TEXT", + required: 0, + unique: 0, + default_value: null, + validation: JSON.stringify({ allowedMimeTypes: ["application/pdf"] }), + widget: "file", + options: null, + sort_order: 10, + }) + .execute(); + + // Add the column to ec_posts + await ctx.db.schema.alterTable("ec_posts").addColumn("attachment", "text").execute(); + + // Seed two media items + pdfMediaId = ulid(); + zipMediaId = ulid(); + await ctx.db + .insertInto("media") + .values([ + { + id: pdfMediaId, + filename: "doc.pdf", + mime_type: "application/pdf", + size: 100, + width: null, + height: null, + alt: null, + caption: null, + storage_key: "doc.pdf", + content_hash: null, + blurhash: null, + dominant_color: null, + status: "ready", + author_id: null, + }, + { + id: zipMediaId, + filename: "x.zip", + mime_type: "application/zip", + size: 100, + width: null, + height: null, + alt: null, + caption: null, + storage_key: "x.zip", + content_hash: null, + blurhash: null, + dominant_color: null, + status: "ready", + author_id: null, + }, + ]) + .execute(); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("accepts a PDF in a PDF-only field", async () => { + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p1", + data: { + title: "p1", + attachment: { id: pdfMediaId, provider: "local", filename: "doc.pdf" }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects a zip in a PDF-only field on create", async () => { + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p2", + data: { + title: "p2", + attachment: { id: zipMediaId, provider: "local", filename: "x.zip" }, + }, + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); + }); + + it("rejects a zip in a PDF-only field on update", async () => { + const created = await handleContentCreate(ctx.db, "posts", { + slug: "p3", + data: { title: "p3" }, + }); + if (!created.success) throw new Error("seed failed"); + + const result = await handleContentUpdate(ctx.db, "posts", created.data.item.id, { + data: { + attachment: { id: zipMediaId, provider: "local", filename: "x.zip" }, + }, + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); + }); + + it("validates external-provider values via the value's mimeType", async () => { + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p4", + data: { + title: "p4", + attachment: { + id: "ext-1", + provider: "s3", + filename: "remote.zip", + mimeType: "application/zip", + src: "https://example.com/remote.zip", + }, + }, + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); + }); +}); From 489145360200de5b2fbf225e1cdf82ed6f25aa40 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:01:04 +0300 Subject: [PATCH 09/26] fix(core): describeEachDialect, JOIN query, and missing test cases in media-field validation - Wraps media-field-validation test in describeEachDialect so both SQLite and Postgres dialects are exercised - Replaces two-query approach (collection lookup + fields query) with a single inner JOIN in loadMediaFieldsForCollection - Adds test case asserting file fields without allowedMimeTypes are not validated (backwards-compat) - Adds test case asserting a local media ID missing from the DB returns INVALID_MIME_FOR_FIELD Co-Authored-By: Claude Sonnet 4.6 --- .../src/api/handlers/validate-media-fields.ts | 16 ++--- .../content/media-field-validation.test.ts | 65 ++++++++++++++++++- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index 4d1b2add9..c2ed2de5c 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -27,18 +27,14 @@ async function loadMediaFieldsForCollection( db: Kysely, collectionSlug: string, ): Promise { - const collection = await db - .selectFrom("_emdash_collections") - .select("id") - .where("slug", "=", collectionSlug) - .executeTakeFirst(); - if (!collection) return []; - const rows = await db .selectFrom("_emdash_fields") - .select(["slug", "type", "validation"]) - .where("collection_id", "=", collection.id) - .where((eb) => eb.or([eb("type", "=", "file"), eb("type", "=", "image")])) + .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") + .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"]) + .where("_emdash_collections.slug", "=", collectionSlug) + .where((eb) => + eb.or([eb("_emdash_fields.type", "=", "file"), eb("_emdash_fields.type", "=", "image")]), + ) .execute(); const out: FieldRow[] = []; diff --git a/packages/core/tests/integration/content/media-field-validation.test.ts b/packages/core/tests/integration/content/media-field-validation.test.ts index 64fd28bc6..d3dc30ed7 100644 --- a/packages/core/tests/integration/content/media-field-validation.test.ts +++ b/packages/core/tests/integration/content/media-field-validation.test.ts @@ -1,21 +1,22 @@ import { ulid } from "ulidx"; -import { it, expect, describe, beforeEach, afterEach } from "vitest"; +import { it, expect, beforeEach, afterEach } from "vitest"; import { handleContentCreate, handleContentUpdate } from "../../../src/api/handlers/content.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { + describeEachDialect, setupForDialect, teardownForDialect, type DialectTestContext, } from "../../utils/test-db.js"; -describe("save-side media-field MIME validation", () => { +describeEachDialect("save-side media-field MIME validation", (dialect) => { let ctx: DialectTestContext; let pdfMediaId: string; let zipMediaId: string; beforeEach(async () => { - ctx = await setupForDialect("sqlite"); + ctx = await setupForDialect(dialect); // Create a posts collection with title and attachment fields const registry = new SchemaRegistry(ctx.db); @@ -165,4 +166,62 @@ describe("save-side media-field MIME validation", () => { if (result.success) return; expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); }); + + it("file/image field without allowedMimeTypes is not validated", async () => { + // Insert a second file field with no MIME restrictions (backwards-compat assertion) + const collection = await ctx.db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", "posts") + .executeTakeFirstOrThrow(); + + await ctx.db + .insertInto("_emdash_fields") + .values({ + id: ulid(), + collection_id: collection.id, + slug: "unconstrained", + label: "Unconstrained", + type: "file", + column_type: "TEXT", + required: 0, + unique: 0, + default_value: null, + validation: null, + widget: "file", + options: null, + sort_order: 20, + }) + .execute(); + + await ctx.db.schema.alterTable("ec_posts").addColumn("unconstrained", "text").execute(); + + // Attaching a zip to the unconstrained field alongside a valid PDF in the + // constrained field — the save should succeed because unconstrained has no + // allowedMimeTypes. + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p5", + data: { + title: "p5", + attachment: { id: pdfMediaId, provider: "local", filename: "doc.pdf" }, + unconstrained: { id: zipMediaId, provider: "local", filename: "x.zip" }, + }, + }); + expect(result.success).toBe(true); + }); + + it("local media ID not found in DB returns INVALID_MIME_FOR_FIELD", async () => { + // Reference a made-up ULID that doesn't exist in the media table + const missingId = ulid(); + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p6", + data: { + title: "p6", + attachment: { id: missingId, provider: "local", filename: "ghost.pdf" }, + }, + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); + }); }); From 200b4e9a81b29a4cc0616af9253c8e9786210a3e Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:08:28 +0300 Subject: [PATCH 10/26] feat(admin): allowed-types control for file/image fields in schema editor Adds AllowedTypesEditor component with preset toggles and manual MIME/extension entry, wired into FieldEditor for file and image field types. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/AllowedTypesEditor.tsx | 204 ++++++++++++++++++ packages/admin/src/components/FieldEditor.tsx | 18 ++ packages/admin/src/lib/api/schema.ts | 3 + 3 files changed, 225 insertions(+) create mode 100644 packages/admin/src/components/AllowedTypesEditor.tsx diff --git a/packages/admin/src/components/AllowedTypesEditor.tsx b/packages/admin/src/components/AllowedTypesEditor.tsx new file mode 100644 index 000000000..9a66592f8 --- /dev/null +++ b/packages/admin/src/components/AllowedTypesEditor.tsx @@ -0,0 +1,204 @@ +import { Button, Input, Label } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { Plus, X } from "@phosphor-icons/react"; +import * as React from "react"; + +import { cn } from "../lib/utils"; + +interface Preset { + key: string; + mimeTypes: string[]; +} + +const PRESETS: ReadonlyArray = [ + { key: "images", mimeTypes: ["image/"] }, + { key: "pdf", mimeTypes: ["application/pdf"] }, + { + key: "documents", + mimeTypes: [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain", + "application/rtf", + ], + }, + { + key: "spreadsheets", + mimeTypes: [ + "text/csv", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + }, + { key: "archives", mimeTypes: ["application/zip", "application/x-tar", "application/gzip"] }, + { key: "audio", mimeTypes: ["audio/"] }, + { key: "video", mimeTypes: ["video/"] }, + { key: "captions", mimeTypes: ["text/vtt", "application/x-subrip"] }, + { key: "fonts", mimeTypes: ["font/", "application/font-woff"] }, +]; + +const EXTENSION_TO_MIME: Record = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".txt": "text/plain", + ".rtf": "application/rtf", + ".vtt": "text/vtt", + ".srt": "application/x-subrip", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +function expandShorthand(entry: string): string | null { + const trimmed = entry.trim(); + if (!trimmed) return null; + if (trimmed.includes("/")) return trimmed; + if (trimmed.startsWith(".")) return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null; + return null; +} + +export interface AllowedTypesEditorProps { + value: string[]; + onChange: (next: string[]) => void; +} + +export function AllowedTypesEditor({ value, onChange }: AllowedTypesEditorProps) { + const { t } = useLingui(); + const [draft, setDraft] = React.useState(""); + const [warning, setWarning] = React.useState(null); + + const presetLabels: Record = { + images: t`Images`, + pdf: t`PDF`, + documents: t`Documents`, + spreadsheets: t`Spreadsheets`, + archives: t`Archives`, + audio: t`Audio`, + video: t`Video`, + captions: t`Captions / Subtitles`, + fonts: t`Fonts`, + }; + + const set = React.useMemo(() => new Set(value), [value]); + + const togglePreset = (preset: Preset) => { + const allIncluded = preset.mimeTypes.every((m) => set.has(m)); + const next = new Set(value); + if (allIncluded) { + for (const m of preset.mimeTypes) next.delete(m); + } else { + for (const m of preset.mimeTypes) next.add(m); + } + onChange([...next]); + }; + + const addDraft = () => { + const expanded = expandShorthand(draft); + if (!expanded) { + setWarning(t`Couldn't map "${draft}" to a MIME type. Type the MIME directly.`); + return; + } + setWarning(null); + if (!set.has(expanded)) onChange([...value, expanded]); + setDraft(""); + }; + + const removeEntry = (entry: string) => { + onChange(value.filter((v) => v !== entry)); + }; + + return ( +
+ +

+ {value.length === 0 + ? t`Any media type allowed (subject to global limits).` + : t`Only the listed MIME types will be accepted for this field.`} +

+ +
+ {PRESETS.map((preset) => { + const allIncluded = preset.mimeTypes.every((m) => set.has(m)); + return ( + + ); + })} +
+ + {value.length > 0 && ( +
    + {value.map((entry) => ( +
  • + {entry} + +
  • + ))} +
+ )} + +
+ { + setDraft(e.target.value); + setWarning(null); + }} + placeholder={t`e.g. application/zip or .pdf`} + aria-label={t`Add MIME type or extension`} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addDraft(); + } + }} + /> + +
+ {warning &&

{warning}

} +
+ ); +} diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 62a9a78b7..4d50068e3 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -24,6 +24,7 @@ import * as React from "react"; import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api"; import { cn } from "../lib/utils"; +import { AllowedTypesEditor } from "./AllowedTypesEditor"; // ============================================================================ // Constants @@ -75,6 +76,7 @@ interface FieldFormState { subFields: RepeaterSubFieldState[]; minItems: string; maxItems: string; + allowedMimeTypes: string[]; } function getInitialFormState(field?: SchemaField): FieldFormState { @@ -98,6 +100,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { : [], minItems: (field.validation as Record)?.minItems?.toString() ?? "", maxItems: (field.validation as Record)?.maxItems?.toString() ?? "", + allowedMimeTypes: (field.validation?.allowedMimeTypes as string[] | undefined) ?? [], }; } return { @@ -117,6 +120,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { subFields: [], minItems: "", maxItems: "", + allowedMimeTypes: [], }; } @@ -300,6 +304,13 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie (validation as Record).maxItems = parseInt(formState.maxItems, 10); } + if ( + (selectedType === "file" || selectedType === "image") && + formState.allowedMimeTypes.length > 0 + ) { + validation.allowedMimeTypes = formState.allowedMimeTypes; + } + // Only include searchable for text-based fields const isSearchableType = selectedType === "string" || @@ -636,6 +647,13 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie )} + + {(selectedType === "file" || selectedType === "image") && ( + setField("allowedMimeTypes", next)} + /> + )} )} diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index 223d2eea0..b066d47fe 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -59,6 +59,7 @@ export interface SchemaField { maxLength?: number; pattern?: string; options?: string[]; + allowedMimeTypes?: string[]; }; widget?: string; options?: Record; @@ -110,6 +111,7 @@ export interface CreateFieldInput { maxLength?: number; pattern?: string; options?: string[]; + allowedMimeTypes?: string[]; }; widget?: string; options?: Record; @@ -128,6 +130,7 @@ export interface UpdateFieldInput { maxLength?: number; pattern?: string; options?: string[]; + allowedMimeTypes?: string[]; }; widget?: string; options?: Record; From 61b9e96f25cb4c23f3e974850cfe8d20528d2ef9 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:22:38 +0300 Subject: [PATCH 11/26] fix(admin): merge duplicate import and add AllowedTypesEditor FieldEditor tests Merges the stray `import { X } from "@phosphor-icons/react"` into the main @phosphor-icons/react import block in FieldEditor.tsx, fixing the oxlint no-duplicate-imports CI gate. Adds three tests in the "config step (file field)" describe block: - AllowedTypesEditor renders for file type fields - AllowedTypesEditor renders for image type fields - Pre-populated allowedMimeTypes from existing field validation appear Co-Authored-By: Claude Sonnet 4.6 --- packages/admin/src/components/FieldEditor.tsx | 2 +- .../tests/components/FieldEditor.test.tsx | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 4d50068e3..f33725793 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -18,8 +18,8 @@ import { Rows, Plus, Trash, + X, } from "@phosphor-icons/react"; -import { X } from "@phosphor-icons/react"; import * as React from "react"; import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api"; diff --git a/packages/admin/tests/components/FieldEditor.test.tsx b/packages/admin/tests/components/FieldEditor.test.tsx index 0a5f9497d..007e2e199 100644 --- a/packages/admin/tests/components/FieldEditor.test.tsx +++ b/packages/admin/tests/components/FieldEditor.test.tsx @@ -338,6 +338,49 @@ describe("FieldEditor", () => { }); }); + describe("config step (file field)", () => { + const fileField = makeField({ + slug: "attachment", + label: "Attachment", + type: "file", + required: false, + unique: false, + searchable: false, + }); + + it("shows AllowedTypesEditor for file type", async () => { + const screen = await render(); + await expect.element(screen.getByText("Allowed types")).toBeInTheDocument(); + }); + + it("shows AllowedTypesEditor for image type", async () => { + const imageField = makeField({ + slug: "cover", + label: "Cover", + type: "image", + required: false, + unique: false, + searchable: false, + }); + const screen = await render(); + await expect.element(screen.getByText("Allowed types")).toBeInTheDocument(); + }); + + it("pre-populates allowedMimeTypes from existing field validation", async () => { + const fieldWithMimes = makeField({ + slug: "document", + label: "Document", + type: "file", + required: false, + unique: false, + searchable: false, + validation: { allowedMimeTypes: ["application/pdf"] }, + }); + const screen = await render(); + await expect.element(screen.getByText("application/pdf")).toBeInTheDocument(); + }); + }); + describe("dialog closed", () => { it("renders nothing visible when open is false", async () => { const screen = await render(); From fe148fced95bef047380a30cf3472818fd88396c Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:31:21 +0300 Subject: [PATCH 12/26] feat(admin): forward allowedMimeTypes and fieldId from schema to renderers (1/2) Co-Authored-By: Claude Sonnet 4.6 --- .../admin/src/components/ContentEditor.tsx | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 685289741..cd6e8e484 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -83,6 +83,7 @@ import { TranslationsPanel } from "./TranslationsPanel.js"; const ROLE_EDITOR = 40; export interface FieldDescriptor { + id?: string; kind: string; label?: string; required?: boolean; @@ -1306,6 +1307,12 @@ function FieldRenderer({ value={imageValue} onChange={handleChange} required={field.required} + allowedMimeTypes={ + Array.isArray(field.validation?.allowedMimeTypes) + ? (field.validation.allowedMimeTypes as string[]) + : undefined + } + fieldId={field.id} /> ); } @@ -1324,6 +1331,12 @@ function FieldRenderer({ value={fileValue} onChange={handleChange} required={field.required} + allowedMimeTypes={ + Array.isArray(field.validation?.allowedMimeTypes) + ? (field.validation.allowedMimeTypes as string[]) + : undefined + } + fieldId={field.id} /> ); } @@ -1559,6 +1572,8 @@ interface ImageFieldRendererProps { value: ImageFieldValue | string | undefined; onChange: (value: ImageFieldValue | null) => void; required?: boolean; + allowedMimeTypes?: string[]; + fieldId?: string; } function ImageFieldRenderer({ @@ -1568,6 +1583,8 @@ function ImageFieldRenderer({ value, onChange, required, + allowedMimeTypes, + fieldId, }: ImageFieldRendererProps) { const { t } = useLingui(); const [pickerOpen, setPickerOpen] = React.useState(false); @@ -1641,7 +1658,10 @@ function ImageFieldRenderer({ open={pickerOpen} onOpenChange={setPickerOpen} onSelect={handleSelect} - mimeTypeFilter="image/" + mimeTypeFilters={ + allowedMimeTypes && allowedMimeTypes.length > 0 ? allowedMimeTypes : ["image/"] + } + fieldId={fieldId} title={t`Select ${label}`} /> {description &&

{description}

} @@ -1675,6 +1695,8 @@ interface FileFieldRendererProps { value: FileFieldValue | undefined; onChange: (value: FileFieldValue | null) => void; required?: boolean; + allowedMimeTypes?: string[]; + fieldId?: string; } /** @@ -1683,7 +1705,15 @@ interface FileFieldRendererProps { * Like ImageFieldRenderer but for arbitrary file types. Shows a mime-type-appropriate * icon, filename, and size instead of an image preview. */ -function FileFieldRenderer({ id, label, value, onChange, required }: FileFieldRendererProps) { +function FileFieldRenderer({ + id, + label, + value, + onChange, + required, + allowedMimeTypes, + fieldId, +}: FileFieldRendererProps) { const { t } = useLingui(); const [pickerOpen, setPickerOpen] = React.useState(false); @@ -1802,7 +1832,8 @@ function FileFieldRenderer({ id, label, value, onChange, required }: FileFieldRe open={pickerOpen} onOpenChange={setPickerOpen} onSelect={handleSelect} - mimeTypeFilter="" + mimeTypeFilters={allowedMimeTypes ?? []} + fieldId={fieldId} hideUrlInput mediaKind="file" title={t`Select ${label}`} From 86c46a60c4bbc739cfeeffba60627ab9b3cca8d8 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:36:59 +0300 Subject: [PATCH 13/26] fix(core): include field id and validation in admin manifest for file/image fields The manifest builder was silently omitting `field.id` and `field.validation` for image/file fields, so allowedMimeTypes and fieldId forwarding added by the ContentEditor never had real values at runtime. Now all fields include their database ID, and file/image fields include their full validation object. Co-Authored-By: Claude Sonnet 4.6 --- packages/admin/src/lib/api/client.ts | 2 ++ packages/core/src/emdash-runtime.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index 53a5c4532..9b0833921 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -59,6 +59,8 @@ export interface AdminManifest { fields: Record< string, { + /** Database row ID (ULID) for the field. Used to widen MIME allowlists on upload/media-list calls. */ + id?: string; kind: string; label?: string; required?: boolean; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 66e53d6b3..9db1c78e9 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1296,6 +1296,9 @@ export class EmDashRuntime { label: field.label, required: field.required, }; + // Always include the field's database ID so the admin can forward it + // to upload/media-list API calls for MIME allowlist widening. + (entry as Record).id = field.id; if (field.widget) entry.widget = field.widget; // Plugin field widgets read their per-field config from `field.options`, // which the seed schema types as `Record`. Pass it @@ -1312,7 +1315,11 @@ export class EmDashRuntime { })); } // Include full validation for repeater fields (subFields, minItems, maxItems) - if (field.type === "repeater" && field.validation) { + // and for file/image fields (allowedMimeTypes). + if ( + (field.type === "repeater" || field.type === "file" || field.type === "image") && + field.validation + ) { (entry as Record).validation = field.validation; } fields[field.slug] = entry; From 035491ceef6161699c4a2123a2aee401294cfb69 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:43:10 +0300 Subject: [PATCH 14/26] fix(core): update ManifestCollection type with field id and validation, remove casts Add `id?` and `validation?` to the field entry type in `ManifestCollection` and in the inline type declaration in `_buildManifest`, replacing the two `as Record` casts with direct property assignments. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/astro/types.ts | 4 ++++ packages/core/src/emdash-runtime.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 31d657710..c14f20626 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -43,6 +43,10 @@ export interface ManifestCollection { * (e.g. a checkbox grid receiving its column definitions) */ options?: Array<{ value: string; label: string }> | Record; + /** The `_emdash_fields` row ID. Used by the admin to forward to upload/media-list API calls. */ + id?: string; + /** Validation config for the field (e.g. `allowedMimeTypes` for file/image fields, subFields for repeater). */ + validation?: Record; } >; } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 9db1c78e9..b060aff18 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1287,6 +1287,8 @@ export class EmDashRuntime { // or arbitrary `Record` for plugin field widgets that // need per-field config (e.g. a checkbox grid receiving its column defs). options?: Array<{ value: string; label: string }> | Record; + id?: string; + validation?: Record; } > = {}; @@ -1298,7 +1300,7 @@ export class EmDashRuntime { }; // Always include the field's database ID so the admin can forward it // to upload/media-list API calls for MIME allowlist widening. - (entry as Record).id = field.id; + entry.id = field.id; if (field.widget) entry.widget = field.widget; // Plugin field widgets read their per-field config from `field.options`, // which the seed schema types as `Record`. Pass it @@ -1320,7 +1322,7 @@ export class EmDashRuntime { (field.type === "repeater" || field.type === "file" || field.type === "image") && field.validation ) { - (entry as Record).validation = field.validation; + entry.validation = field.validation as Record; } fields[field.slug] = entry; } From 59bd04b0d4f78110c19ba44d2ab240bee0e687b6 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:47:28 +0300 Subject: [PATCH 15/26] fix(admin): stub mimeTypeFilters and fieldId on MediaPickerModal for typecheck (wired in Task 11) --- packages/admin/src/components/MediaPickerModal.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 47de9489a..7872b862a 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -54,6 +54,10 @@ export interface MediaPickerModalProps { * Defaults to "image" — set to "file" for generic file pickers. */ mediaKind?: "image" | "file"; + /** MIME allowlist — array of exact MIMEs or `type/` prefixes. Wired up fully in Task 11. */ + mimeTypeFilters?: string[]; + /** `_emdash_fields` row id for server-side MIME widening. Wired up fully in Task 11. */ + fieldId?: string; } /** From 992ac2da5153a433f966cdd3862a5b23f8ab495b Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:52:55 +0300 Subject: [PATCH 16/26] feat(admin): MediaPickerModal accepts mimeTypeFilters[] and fieldId (2/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the stubbed mimeTypeFilters and fieldId props on MediaPickerModal: - fetchMediaList and fetchProviderMedia now accept mimeType as string | string[] - uploadMedia / getUploadUrl / uploadMediaDirect thread fieldId to the API - Component derives a unified `filters` array from mimeTypeFilters (new) or mimeTypeFilter (legacy singular — kept for backward compat) - Query keys, fetch calls, client-side filter, and all use filters - dimensionsMutation cache invalidation uses the same query key as the list query Co-Authored-By: Claude Sonnet 4.6 --- .../admin/src/components/MediaPickerModal.tsx | 49 ++++++++++++++----- packages/admin/src/lib/api/media.ts | 29 +++++++---- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 7872b862a..7de781e7c 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -35,6 +35,24 @@ interface SelectedMedia { item: MediaItem | MediaProviderItem; } +/** + * Returns true if the given MIME type matches any entry in the filters array. + * Each filter entry is either an exact MIME type (e.g. "image/png") or a + * type prefix ending with "/" (e.g. "image/"). + */ +function matchesAnyFilter(mime: string, filters: string[] | undefined): boolean { + if (!filters || filters.length === 0) return true; + for (const entry of filters) { + if (!entry || !entry.includes("/")) continue; + if (entry.endsWith("/")) { + if (mime.startsWith(entry)) return true; + } else if (mime === entry) { + return true; + } + } + return false; +} + export interface MediaPickerModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -81,12 +99,22 @@ export function MediaPickerModal({ onOpenChange, onSelect, mimeTypeFilter = "image/", + mimeTypeFilters, + fieldId, title: providedTitle, hideUrlInput = false, mediaKind = "image", }: MediaPickerModalProps) { const { t } = useLingui(); const isFileKind = mediaKind === "file"; + + // Unified filters: mimeTypeFilters (plural array) takes precedence over the + // legacy mimeTypeFilter (singular string). + const filters = React.useMemo(() => { + if (mimeTypeFilters && mimeTypeFilters.length > 0) return mimeTypeFilters; + if (mimeTypeFilter && mimeTypeFilter.length > 0) return [mimeTypeFilter]; + return undefined; + }, [mimeTypeFilters, mimeTypeFilter]); const title = providedTitle ?? (isFileKind ? t`Select File` : t`Select Image`); const emptyStateUploadHint = isFileKind ? t`Upload a file to get started` @@ -146,10 +174,10 @@ export function MediaPickerModal({ // Fetch local media list const { data: localData, isLoading: localLoading } = useQuery({ - queryKey: ["media", mimeTypeFilter], + queryKey: ["media", filters?.join(",") ?? ""], queryFn: () => fetchMediaList({ - mimeType: mimeTypeFilter, + mimeType: filters, limit: 50, }), enabled: open && activeProvider === "local", @@ -157,10 +185,10 @@ export function MediaPickerModal({ // Fetch provider media list const { data: providerData, isLoading: providerLoading } = useQuery({ - queryKey: ["provider-media", activeProvider, mimeTypeFilter, searchQuery], + queryKey: ["provider-media", activeProvider, filters?.join(",") ?? "", searchQuery], queryFn: () => fetchProviderMedia(activeProvider, { - mimeType: mimeTypeFilter, + mimeType: filters, limit: 50, query: searchQuery || undefined, }), @@ -173,7 +201,7 @@ export function MediaPickerModal({ // Upload mutation for local provider const uploadLocalMutation = useMutation({ - mutationFn: (file: File) => uploadMedia(file), + mutationFn: (file: File) => uploadMedia(file, { fieldId }), onSuccess: (item) => { void queryClient.invalidateQueries({ queryKey: ["media"] }); setSelectedItem({ providerId: "local", item }); @@ -209,7 +237,7 @@ export function MediaPickerModal({ updateMedia(id, { width, height }), onSuccess: (_updated, { id, width, height }) => { queryClient.setQueryData( - ["media", mimeTypeFilter], + ["media", filters?.join(",") ?? ""], (old: { items: MediaItem[]; nextCursor?: string } | undefined) => { if (!old) return old; return { @@ -245,11 +273,10 @@ export function MediaPickerModal({ const items = React.useMemo(() => { if (activeProvider === "local") { const localItems = localData?.items || []; - if (!mimeTypeFilter) return localItems; - return localItems.filter((item) => item.mimeType.startsWith(mimeTypeFilter)); + return localItems.filter((item) => matchesAnyFilter(item.mimeType, filters)); } return providerData?.items || []; - }, [activeProvider, localData?.items, providerData?.items, mimeTypeFilter]); + }, [activeProvider, localData?.items, providerData?.items, filters]); const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files; @@ -492,10 +519,10 @@ export function MediaPickerModal({ )} diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index a63651cd7..a3bf01fdb 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -35,12 +35,15 @@ export interface MediaItem { export async function fetchMediaList(options?: { cursor?: string; limit?: number; - mimeType?: string; + mimeType?: string | string[]; }): Promise> { const params = new URLSearchParams(); if (options?.cursor) params.set("cursor", options.cursor); if (options?.limit) params.set("limit", String(options.limit)); - if (options?.mimeType) params.set("mimeType", options.mimeType); + if (options?.mimeType) { + const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; + if (value) params.set("mimeType", value); + } const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); @@ -63,7 +66,10 @@ interface UploadUrlResponse { * Try to get a signed upload URL * Returns null if signed URLs are not supported (e.g., local storage) */ -async function getUploadUrl(file: File): Promise { +async function getUploadUrl( + file: File, + opts?: { fieldId?: string }, +): Promise { try { const response = await apiFetch(`${API_BASE}/media/upload-url`, { method: "POST", @@ -72,6 +78,7 @@ async function getUploadUrl(file: File): Promise { filename: file.name, contentType: file.type, size: file.size, + ...(opts?.fieldId ? { fieldId: opts.fieldId } : {}), }), }); @@ -147,7 +154,7 @@ async function getImageDimensions(file: File): Promise<{ width: number; height: /** * Upload media file via direct upload (legacy/local storage) */ -async function uploadMediaDirect(file: File): Promise { +async function uploadMediaDirect(file: File, opts?: { fieldId?: string }): Promise { // Get image dimensions before upload const dimensions = await getImageDimensions(file); @@ -156,6 +163,7 @@ async function uploadMediaDirect(file: File): Promise { // Send dimensions as form fields if (dimensions?.width) formData.append("width", String(dimensions.width)); if (dimensions?.height) formData.append("height", String(dimensions.height)); + if (opts?.fieldId) formData.append("fieldId", opts.fieldId); const response = await apiFetch(`${API_BASE}/media`, { method: "POST", @@ -171,13 +179,13 @@ async function uploadMediaDirect(file: File): Promise { * Tries signed URL upload first (for S3/R2 storage), falls back to direct upload * (for local storage) if signed URLs are not supported. */ -export async function uploadMedia(file: File): Promise { +export async function uploadMedia(file: File, opts?: { fieldId?: string }): Promise { // Try to get a signed upload URL - const uploadInfo = await getUploadUrl(file); + const uploadInfo = await getUploadUrl(file, opts); if (!uploadInfo) { // Signed URLs not supported, use direct upload - return uploadMediaDirect(file); + return uploadMediaDirect(file, opts); } // Upload directly to storage via signed URL @@ -274,14 +282,17 @@ export async function fetchProviderMedia( cursor?: string; limit?: number; query?: string; - mimeType?: string; + mimeType?: string | string[]; }, ): Promise> { const params = new URLSearchParams(); if (options?.cursor) params.set("cursor", options.cursor); if (options?.limit) params.set("limit", String(options.limit)); if (options?.query) params.set("query", options.query); - if (options?.mimeType) params.set("mimeType", options.mimeType); + if (options?.mimeType) { + const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; + if (value) params.set("mimeType", value); + } const url = `${API_BASE}/media/providers/${providerId}${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); From 9148a494b7c925ba5e7e74dbfe59b5e1d818e9db Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 20:59:58 +0300 Subject: [PATCH 17/26] fix(admin): translate type/ prefix to type/* in input accept attribute --- packages/admin/src/components/MediaPickerModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 7de781e7c..30e091fe1 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -519,7 +519,11 @@ export function MediaPickerModal({ (f.endsWith("/") ? f + "*" : f)).join(",") + : undefined + } className="sr-only" onChange={handleFileSelect} aria-label={t`Upload file`} From d2313de94a238d805faf8e4ab79f58d434a89056 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 21:02:02 +0300 Subject: [PATCH 18/26] chore: add changeset for per-field allowed media types --- .changeset/media-allowed-types.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/media-allowed-types.md diff --git a/.changeset/media-allowed-types.md b/.changeset/media-allowed-types.md new file mode 100644 index 000000000..e1b528bda --- /dev/null +++ b/.changeset/media-allowed-types.md @@ -0,0 +1,8 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds per-field allowed MIME types for `file` and `image` fields. Field-level `allowedTypes` is now honored end-to-end: it filters the media picker, widens upload acceptance for that field (so e.g. a zip-only field can accept zip uploads even though the global allowlist excludes them), and validates referenced media against the destination field on content save. The schema editor in admin gains an "Allowed types" control with curated presets and freeform entry. + +Behavior change: the `image` builder's `allowedTypes` option was previously accepted but read by nothing. It is now load-bearing — a code-first schema that already passed `allowedTypes` (e.g. `["image/png"]`) will now actually narrow the picker and gate uploads. Most users will see no change; if you set this option intending the old (silent) behavior, drop it. From df139ee4ec1b028cd2f26f32afec48a00eb54928 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 21:12:41 +0300 Subject: [PATCH 19/26] fix(lint): resolve unsafe type assertions introduced by this branch - Replace JSON.parse(...) as T with unknown intermediary + type guard in validate-media-fields.ts and media-allowlist.ts - Spread field.validation instead of casting to Record in emdash-runtime.ts - Remove unnecessary as string[] | undefined assertion in FieldEditor.tsx Co-Authored-By: Claude Sonnet 4.6 --- packages/admin/src/components/FieldEditor.tsx | 2 +- packages/core/src/api/handlers/media-allowlist.ts | 5 +++-- packages/core/src/api/handlers/validate-media-fields.ts | 5 +++-- packages/core/src/emdash-runtime.ts | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index f33725793..9ef40eb9b 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -100,7 +100,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { : [], minItems: (field.validation as Record)?.minItems?.toString() ?? "", maxItems: (field.validation as Record)?.maxItems?.toString() ?? "", - allowedMimeTypes: (field.validation?.allowedMimeTypes as string[] | undefined) ?? [], + allowedMimeTypes: field.validation?.allowedMimeTypes ?? [], }; } return { diff --git a/packages/core/src/api/handlers/media-allowlist.ts b/packages/core/src/api/handlers/media-allowlist.ts index 1685ef6be..7197b85b3 100644 --- a/packages/core/src/api/handlers/media-allowlist.ts +++ b/packages/core/src/api/handlers/media-allowlist.ts @@ -36,8 +36,9 @@ export async function resolveFieldAllowlist( if (!row.validation) return null; try { - const parsed = JSON.parse(row.validation) as { allowedMimeTypes?: string[] }; - const list = parsed.allowedMimeTypes; + const parsed: unknown = JSON.parse(row.validation); + if (typeof parsed !== "object" || parsed === null) return null; + const list = (parsed as { allowedMimeTypes?: string[] }).allowedMimeTypes; if (!list || list.length === 0) return null; return list; } catch { diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index c2ed2de5c..b489ea14c 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -41,8 +41,9 @@ async function loadMediaFieldsForCollection( for (const row of rows) { if (!row.validation) continue; try { - const parsed = JSON.parse(row.validation) as { allowedMimeTypes?: string[] }; - const list = parsed.allowedMimeTypes; + const parsed: unknown = JSON.parse(row.validation); + if (typeof parsed !== "object" || parsed === null) continue; + const list = (parsed as { allowedMimeTypes?: string[] }).allowedMimeTypes; if (!list || list.length === 0) continue; out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list }); } catch { diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index b060aff18..9edd2226c 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1322,7 +1322,7 @@ export class EmDashRuntime { (field.type === "repeater" || field.type === "file" || field.type === "image") && field.validation ) { - entry.validation = field.validation as Record; + entry.validation = { ...field.validation }; } fields[field.slug] = entry; } From 879c3dbd2d5255b48d2a94382c7560373913e09b Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 7 May 2026 21:17:04 +0300 Subject: [PATCH 20/26] Delete redundant changeset --- .changeset/upset-cougars-sniff.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/upset-cougars-sniff.md diff --git a/.changeset/upset-cougars-sniff.md b/.changeset/upset-cougars-sniff.md deleted file mode 100644 index c94f981e6..000000000 --- a/.changeset/upset-cougars-sniff.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"emdash": minor ---- - -Adds save-side MIME validation for `file` and `image` fields with `allowedMimeTypes` constraints. Content creates and updates now reject media references whose MIME type is not in the field's allowlist, returning `INVALID_MIME_FOR_FIELD`. From 437827b834d55c6fce97fc41e614566249729b86 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 8 May 2026 00:30:53 +0300 Subject: [PATCH 21/26] Fix allowed types being dropped from schema --- packages/core/src/api/schemas/schema.ts | 1 + packages/core/tests/unit/api/schemas.test.ts | 22 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index df15a10b6..e43d7ac59 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -49,6 +49,7 @@ const fieldValidation = z subFields: z.array(repeaterSubFieldSchema).min(1).optional(), minItems: z.number().int().min(0).optional(), maxItems: z.number().int().min(1).optional(), + allowedMimeTypes: z.array(z.string()).optional(), }) .optional(); diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index 1ed9327ec..13be8052b 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect } from "vitest"; import { contentCreateBody, contentUpdateBody, + createFieldBody, + updateFieldBody, httpUrl, mediaUploadUrlBody, DEFAULT_MAX_UPLOAD_SIZE, @@ -153,6 +155,26 @@ describe("httpUrl validator", () => { }); }); +describe("createFieldBody / updateFieldBody — allowedMimeTypes", () => { + it("preserves allowedMimeTypes through createFieldBody parse", () => { + const result = createFieldBody.parse({ + slug: "attachment", + label: "Attachment", + type: "file", + validation: { allowedMimeTypes: ["application/pdf"] }, + }); + expect(result.validation?.allowedMimeTypes).toEqual(["application/pdf"]); + }); + + it("preserves allowedMimeTypes through updateFieldBody parse", () => { + const result = updateFieldBody.parse({ + label: "Attachment", + validation: { allowedMimeTypes: ["font/", "application/font-woff"] }, + }); + expect(result.validation?.allowedMimeTypes).toEqual(["font/", "application/font-woff"]); + }); +}); + describe("mediaUploadUrlBody schema factory", () => { it("DEFAULT_MAX_UPLOAD_SIZE is 50 MB", () => { expect(DEFAULT_MAX_UPLOAD_SIZE).toBe(50 * 1024 * 1024); From ed4b03c3c91237498495a89cb5b9c7a68a067197 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 8 May 2026 09:47:34 +0300 Subject: [PATCH 22/26] Address adversarial reviewer feedback --- packages/admin/src/components/FieldEditor.tsx | 2 +- .../admin/src/components/MediaPickerModal.tsx | 2 +- packages/admin/src/lib/api/schema.ts | 4 +-- .../core/src/api/handlers/media-allowlist.ts | 10 ++++++ .../src/api/handlers/validate-media-fields.ts | 16 +++++----- packages/core/src/api/schemas/schema.ts | 4 +-- packages/core/src/astro/routes/api/media.ts | 6 ++-- .../src/astro/routes/api/media/upload-url.ts | 6 ++-- .../core/src/database/repositories/media.ts | 8 ++++- packages/core/src/media/mime.ts | 10 ++++-- packages/core/src/schema/registry.ts | 13 +++++--- packages/core/src/schema/types.ts | 2 +- .../content/media-field-validation.test.ts | 8 ++--- packages/core/tests/unit/media/mime.test.ts | 31 ++++++++++++++++++- 14 files changed, 88 insertions(+), 34 deletions(-) diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index fbc033688..6d95063e6 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -326,7 +326,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie required, unique, searchable: isSearchableType ? searchable : undefined, - validation: Object.keys(validation).length > 0 ? validation : undefined, + validation: Object.keys(validation).length > 0 ? validation : null, }; onSave(input); diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 31af1be5b..e8e2e85f2 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -114,7 +114,7 @@ export function MediaPickerModal({ // Unified filters: mimeTypeFilters (plural array) takes precedence over the // legacy mimeTypeFilter (singular string). const filters = React.useMemo(() => { - if (mimeTypeFilters && mimeTypeFilters.length > 0) return mimeTypeFilters; + if (mimeTypeFilters !== undefined) return mimeTypeFilters.length > 0 ? mimeTypeFilters : undefined; if (mimeTypeFilter && mimeTypeFilter.length > 0) return [mimeTypeFilter]; return undefined; }, [mimeTypeFilters, mimeTypeFilter]); diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index 7293a526a..1b991befa 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -115,7 +115,7 @@ export interface CreateFieldInput { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; - }; + } | null; widget?: string; options?: Record; } @@ -134,7 +134,7 @@ export interface UpdateFieldInput { pattern?: string; options?: string[]; allowedMimeTypes?: string[]; - }; + } | null; widget?: string; options?: Record; sortOrder?: number; diff --git a/packages/core/src/api/handlers/media-allowlist.ts b/packages/core/src/api/handlers/media-allowlist.ts index 7197b85b3..6ac2dd5db 100644 --- a/packages/core/src/api/handlers/media-allowlist.ts +++ b/packages/core/src/api/handlers/media-allowlist.ts @@ -1,7 +1,15 @@ import type { Kysely } from "kysely"; +import { hasPermission } from "@emdash-cms/auth"; +import type { RoleLevel } from "@emdash-cms/auth"; + import type { Database } from "../../database/types.js"; +interface UserLike { + id: string; + role: RoleLevel; +} + /** * MIME types allowed for upload by default (when no field-specific list * overrides this). Entries ending with "/" are prefix-matched (e.g. @@ -24,7 +32,9 @@ export const GLOBAL_UPLOAD_ALLOWLIST: readonly string[] = [ export async function resolveFieldAllowlist( db: Kysely, fieldId: string, + user: UserLike | null | undefined, ): Promise { + if (!hasPermission(user, "content:create")) return null; const row = await db .selectFrom("_emdash_fields") .select(["type", "validation"]) diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index b489ea14c..016b8ba89 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -2,6 +2,7 @@ import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; import { matchesMimeAllowlist } from "../../media/mime.js"; +import { requestCached } from "../../request-cache.js"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import type { ApiResult } from "../types.js"; @@ -58,7 +59,9 @@ export async function validateMediaFields( collectionSlug: string, data: Record, ): Promise> { - const fields = await loadMediaFieldsForCollection(db, collectionSlug); + const fields = await requestCached(`mediaFields:${collectionSlug}`, () => + loadMediaFieldsForCollection(db, collectionSlug), + ); if (fields.length === 0) return { success: true, data: true }; // Collect local media ids that need a MIME lookup @@ -93,13 +96,10 @@ export async function validateMediaFields( if (!ref) continue; const provider = typeof ref.provider === "string" ? ref.provider : "local"; - let mime: string | undefined; - if (provider === "local") { - if (typeof ref.id !== "string") continue; - mime = mimeById.get(ref.id); - } else { - if (typeof ref.mimeType === "string") mime = ref.mimeType; - } + if (provider !== "local") continue; + + if (typeof ref.id !== "string") continue; + const mime = mimeById.get(ref.id); if (!mime) { return { diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index e43d7ac59..4d508d587 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -93,7 +93,7 @@ export const createFieldBody = z required: z.boolean().optional(), unique: z.boolean().optional(), defaultValue: z.unknown().optional(), - validation: fieldValidation, + validation: fieldValidation.nullable(), widget: z.string().optional(), options: fieldWidgetOptions, sortOrder: z.number().int().min(0).optional(), @@ -108,7 +108,7 @@ export const updateFieldBody = z required: z.boolean().optional(), unique: z.boolean().optional(), defaultValue: z.unknown().optional(), - validation: fieldValidation, + validation: fieldValidation.nullable(), widget: z.string().optional(), options: fieldWidgetOptions, sortOrder: z.number().int().min(0).optional(), diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index 9ad11bc2c..d42192630 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -16,7 +16,7 @@ import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/me import { isParseError, parseQuery } from "#api/parse.js"; import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js"; import { MediaRepository } from "#db/repositories/media.js"; -import { matchesMimeAllowlist } from "#media/mime.js"; +import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js"; import { generatePlaceholder } from "#media/placeholder.js"; import { computeContentHash } from "#utils/hash.js"; @@ -113,7 +113,7 @@ export const POST: APIRoute = async ({ request, locals }) => { const fieldId = typeof fieldIdEntry === "string" && fieldIdEntry.length > 0 ? fieldIdEntry : null; - const fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId) : null; + const fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId, user) : null; const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST]; if (!matchesMimeAllowlist(file.type, allowlist)) { @@ -182,7 +182,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Create media record const result = await emdash.handleMediaCreate({ filename: file.name, - mimeType: file.type, + mimeType: normalizeMime(file.type), size: file.size, width, height, diff --git a/packages/core/src/astro/routes/api/media/upload-url.ts b/packages/core/src/astro/routes/api/media/upload-url.ts index dd7c884a6..469e2674c 100644 --- a/packages/core/src/astro/routes/api/media/upload-url.ts +++ b/packages/core/src/astro/routes/api/media/upload-url.ts @@ -18,7 +18,7 @@ import { apiError, apiSuccess, handleError } from "#api/error.js"; import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js"; import { isParseError, parseBody } from "#api/parse.js"; import { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from "#api/schemas.js"; -import { matchesMimeAllowlist } from "#media/mime.js"; +import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js"; export const prerender = false; @@ -74,7 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Validate content type (field-aware widening) const fieldAllowlist = body.fieldId - ? await resolveFieldAllowlist(emdash.db, body.fieldId) + ? await resolveFieldAllowlist(emdash.db, body.fieldId, user) : null; const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST]; @@ -106,7 +106,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Create pending media record with content hash const mediaItem = await repo.createPending({ filename: body.filename, - mimeType: body.contentType, + mimeType: normalizeMime(body.contentType), size: body.size, storageKey, contentHash: body.contentHash, diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index 9f895127a..249c86098 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -17,7 +17,13 @@ function escapeLike(value: string): string { function normalizeMimeFilter(input?: string | readonly string[]): string[] { if (!input) return []; const arr = Array.isArray(input) ? input : [input]; - return arr.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + return arr + .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + .map((entry) => + entry.endsWith("/") + ? entry.toLowerCase() + : entry.split(";")[0]!.trim().toLowerCase(), + ); } export type MediaStatus = "pending" | "ready" | "failed"; diff --git a/packages/core/src/media/mime.ts b/packages/core/src/media/mime.ts index 24b6a496a..514f1be3b 100644 --- a/packages/core/src/media/mime.ts +++ b/packages/core/src/media/mime.ts @@ -1,9 +1,15 @@ +export function normalizeMime(mime: string): string { + return mime.split(";")[0]!.trim().toLowerCase(); +} + export function matchesMimeAllowlist(mime: string, allowList: readonly string[]): boolean { + const normalized = normalizeMime(mime); for (const entry of allowList) { if (!entry || !entry.includes("/")) continue; + const normalizedEntry = normalizeMime(entry); if (entry.endsWith("/")) { - if (mime.startsWith(entry)) return true; - } else if (mime === entry) { + if (normalized.startsWith(normalizedEntry)) return true; + } else if (normalized === normalizedEntry) { return true; } } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 8c88fe068..700bef581 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -550,11 +550,14 @@ export class SchemaRegistry { : field.defaultValue !== undefined ? JSON.stringify(field.defaultValue) : null, - validation: input.validation - ? JSON.stringify(input.validation) - : field.validation - ? JSON.stringify(field.validation) - : null, + validation: + input.validation === undefined + ? field.validation + ? JSON.stringify(field.validation) + : null + : input.validation + ? JSON.stringify(input.validation) + : null, widget: input.widget ?? field.widget ?? null, options: input.options ? JSON.stringify(input.options) diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6049ed925..e3df31b91 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -257,7 +257,7 @@ export interface UpdateFieldInput { required?: boolean; unique?: boolean; defaultValue?: unknown; - validation?: FieldValidation; + validation?: FieldValidation | null; widget?: string; options?: FieldWidgetOptions; sortOrder?: number; diff --git a/packages/core/tests/integration/content/media-field-validation.test.ts b/packages/core/tests/integration/content/media-field-validation.test.ts index d3dc30ed7..b3a5cb7e3 100644 --- a/packages/core/tests/integration/content/media-field-validation.test.ts +++ b/packages/core/tests/integration/content/media-field-validation.test.ts @@ -148,7 +148,9 @@ describeEachDialect("save-side media-field MIME validation", (dialect) => { expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); }); - it("validates external-provider values via the value's mimeType", async () => { + it("skips MIME validation for non-local provider refs (cannot verify server-side)", async () => { + // Non-local provider refs cannot be verified server-side without provider introspection, + // so they are intentionally skipped. allowedMimeTypes enforcement only applies to local refs. const result = await handleContentCreate(ctx.db, "posts", { slug: "p4", data: { @@ -162,9 +164,7 @@ describeEachDialect("save-side media-field MIME validation", (dialect) => { }, }, }); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); + expect(result.success).toBe(true); }); it("file/image field without allowedMimeTypes is not validated", async () => { diff --git a/packages/core/tests/unit/media/mime.test.ts b/packages/core/tests/unit/media/mime.test.ts index 78ff7d548..566f0f951 100644 --- a/packages/core/tests/unit/media/mime.test.ts +++ b/packages/core/tests/unit/media/mime.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { matchesMimeAllowlist, expandExtensionShorthand } from "../../../src/media/mime.js"; +import { matchesMimeAllowlist, normalizeMime, expandExtensionShorthand } from "../../../src/media/mime.js"; describe("matchesMimeAllowlist", () => { it("matches exact MIME types", () => { @@ -30,6 +30,35 @@ describe("matchesMimeAllowlist", () => { expect(matchesMimeAllowlist("image/png", ["image"])).toBe(false); expect(matchesMimeAllowlist("image/png", [""])).toBe(false); }); + + it("is case-insensitive per RFC 2045", () => { + expect(matchesMimeAllowlist("Image/JPEG", ["image/jpeg"])).toBe(true); + expect(matchesMimeAllowlist("image/jpeg", ["Image/JPEG"])).toBe(true); + expect(matchesMimeAllowlist("IMAGE/PNG", ["image/"])).toBe(true); + expect(matchesMimeAllowlist("VIDEO/MP4", ["video/"])).toBe(true); + }); + + it("strips MIME parameters before matching", () => { + expect(matchesMimeAllowlist("text/html; charset=utf-8", ["text/html"])).toBe(true); + expect(matchesMimeAllowlist("text/plain; charset=iso-8859-1", ["text/"])).toBe(true); + expect(matchesMimeAllowlist("application/json; charset=utf-8", ["application/pdf"])).toBe(false); + }); +}); + +describe("normalizeMime", () => { + it("lowercases the type", () => { + expect(normalizeMime("Image/JPEG")).toBe("image/jpeg"); + expect(normalizeMime("APPLICATION/PDF")).toBe("application/pdf"); + }); + + it("strips parameters", () => { + expect(normalizeMime("text/html; charset=utf-8")).toBe("text/html"); + expect(normalizeMime("text/plain;charset=iso-8859-1")).toBe("text/plain"); + }); + + it("leaves already-normalized types unchanged", () => { + expect(normalizeMime("image/png")).toBe("image/png"); + }); }); describe("expandExtensionShorthand", () => { From dd718a73e00d2d7e42df4258aea132c05f4317df Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 8 May 2026 12:54:36 +0300 Subject: [PATCH 23/26] Address second adverserial review --- .../src/components/AllowedTypesEditor.tsx | 4 +- .../admin/src/components/MediaPickerModal.tsx | 12 ++++-- .../core/src/api/handlers/media-allowlist.ts | 3 +- .../src/api/handlers/validate-media-fields.ts | 20 ++++++++-- packages/core/src/api/schemas/schema.ts | 8 +++- .../core/src/database/repositories/media.ts | 4 +- .../content/media-field-validation.test.ts | 39 +++++++++++++++++-- packages/core/tests/unit/media/mime.test.ts | 10 ++++- 8 files changed, 81 insertions(+), 19 deletions(-) diff --git a/packages/admin/src/components/AllowedTypesEditor.tsx b/packages/admin/src/components/AllowedTypesEditor.tsx index 9a66592f8..76546c9b0 100644 --- a/packages/admin/src/components/AllowedTypesEditor.tsx +++ b/packages/admin/src/components/AllowedTypesEditor.tsx @@ -66,10 +66,12 @@ const EXTENSION_TO_MIME: Record = { ".woff2": "font/woff2", }; +const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i; + function expandShorthand(entry: string): string | null { const trimmed = entry.trim(); if (!trimmed) return null; - if (trimmed.includes("/")) return trimmed; + if (trimmed.includes("/")) return VALID_MIME_RE.test(trimmed) ? trimmed : null; if (trimmed.startsWith(".")) return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null; return null; } diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index e8e2e85f2..e57843562 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -42,11 +42,13 @@ interface SelectedMedia { */ function matchesAnyFilter(mime: string, filters: string[] | undefined): boolean { if (!filters || filters.length === 0) return true; + const normalizedMime = mime.toLowerCase(); for (const entry of filters) { if (!entry || !entry.includes("/")) continue; - if (entry.endsWith("/")) { - if (mime.startsWith(entry)) return true; - } else if (mime === entry) { + const normalizedEntry = entry.toLowerCase(); + if (normalizedEntry.endsWith("/")) { + if (normalizedMime.startsWith(normalizedEntry)) return true; + } else if (normalizedMime === normalizedEntry) { return true; } } @@ -114,7 +116,8 @@ export function MediaPickerModal({ // Unified filters: mimeTypeFilters (plural array) takes precedence over the // legacy mimeTypeFilter (singular string). const filters = React.useMemo(() => { - if (mimeTypeFilters !== undefined) return mimeTypeFilters.length > 0 ? mimeTypeFilters : undefined; + if (mimeTypeFilters !== undefined) + return mimeTypeFilters.length > 0 ? mimeTypeFilters : undefined; if (mimeTypeFilter && mimeTypeFilter.length > 0) return [mimeTypeFilter]; return undefined; }, [mimeTypeFilters, mimeTypeFilter]); @@ -349,6 +352,7 @@ export function MediaPickerModal({ filename: url.pathname.split("/").pop() || "external-image", mimeType: "image/unknown", url: url.href, + provider: "external-url", size: 0, width: dimensions.width, height: dimensions.height, diff --git a/packages/core/src/api/handlers/media-allowlist.ts b/packages/core/src/api/handlers/media-allowlist.ts index 6ac2dd5db..c6d8cbb61 100644 --- a/packages/core/src/api/handlers/media-allowlist.ts +++ b/packages/core/src/api/handlers/media-allowlist.ts @@ -1,7 +1,6 @@ -import type { Kysely } from "kysely"; - import { hasPermission } from "@emdash-cms/auth"; import type { RoleLevel } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index 016b8ba89..d1b2b726f 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -20,7 +20,7 @@ interface MediaRefValue { function asMediaRef(value: unknown): MediaRefValue | null { if (value === null || value === undefined) return null; - if (typeof value !== "object") return null; + if (typeof value !== "object" || Array.isArray(value)) return null; return value as MediaRefValue; } @@ -48,7 +48,9 @@ async function loadMediaFieldsForCollection( if (!list || list.length === 0) continue; out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list }); } catch { - // Malformed validation JSON — skip + console.warn( + `[emdash] malformed validation JSON for field '${row.slug}' — skipping MIME check`, + ); } } return out; @@ -96,7 +98,19 @@ export async function validateMediaFields( if (!ref) continue; const provider = typeof ref.provider === "string" ? ref.provider : "local"; - if (provider !== "local") continue; + if (provider !== "local") { + if (typeof ref.mimeType !== "string") continue; + if (!matchesMimeAllowlist(ref.mimeType, field.allowedMimeTypes)) { + return { + success: false, + error: { + code: "INVALID_MIME_FOR_FIELD", + message: `Field '${field.slug}' does not accept ${ref.mimeType}`, + }, + }; + } + continue; + } if (typeof ref.id !== "string") continue; const mime = mimeById.get(ref.id); diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index 4d508d587..ee18c0e2d 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -49,7 +49,13 @@ const fieldValidation = z subFields: z.array(repeaterSubFieldSchema).min(1).optional(), minItems: z.number().int().min(0).optional(), maxItems: z.number().int().min(1).optional(), - allowedMimeTypes: z.array(z.string()).optional(), + allowedMimeTypes: z + .array( + z + .string() + .regex(/^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i, "Invalid MIME type"), + ) + .optional(), }) .optional(); diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index 249c86098..8fec6a27e 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -20,9 +20,7 @@ function normalizeMimeFilter(input?: string | readonly string[]): string[] { return arr .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) .map((entry) => - entry.endsWith("/") - ? entry.toLowerCase() - : entry.split(";")[0]!.trim().toLowerCase(), + entry.endsWith("/") ? entry.toLowerCase() : entry.split(";")[0]!.trim().toLowerCase(), ); } diff --git a/packages/core/tests/integration/content/media-field-validation.test.ts b/packages/core/tests/integration/content/media-field-validation.test.ts index b3a5cb7e3..2cfd902d9 100644 --- a/packages/core/tests/integration/content/media-field-validation.test.ts +++ b/packages/core/tests/integration/content/media-field-validation.test.ts @@ -148,9 +148,7 @@ describeEachDialect("save-side media-field MIME validation", (dialect) => { expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); }); - it("skips MIME validation for non-local provider refs (cannot verify server-side)", async () => { - // Non-local provider refs cannot be verified server-side without provider introspection, - // so they are intentionally skipped. allowedMimeTypes enforcement only applies to local refs. + it("rejects external-provider ref when mimeType is present and does not match allowlist", async () => { const result = await handleContentCreate(ctx.db, "posts", { slug: "p4", data: { @@ -164,6 +162,41 @@ describeEachDialect("save-side media-field MIME validation", (dialect) => { }, }, }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); + }); + + it("accepts external-provider ref when mimeType matches the allowlist", async () => { + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p4b", + data: { + title: "p4b", + attachment: { + id: "ext-2", + provider: "s3", + filename: "remote.pdf", + mimeType: "application/pdf", + src: "https://example.com/remote.pdf", + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("skips MIME validation for external-provider ref with no mimeType field", async () => { + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p4c", + data: { + title: "p4c", + attachment: { + id: "ext-3", + provider: "s3", + filename: "remote-unknown", + src: "https://example.com/remote-unknown", + }, + }, + }); expect(result.success).toBe(true); }); diff --git a/packages/core/tests/unit/media/mime.test.ts b/packages/core/tests/unit/media/mime.test.ts index 566f0f951..4fd247a92 100644 --- a/packages/core/tests/unit/media/mime.test.ts +++ b/packages/core/tests/unit/media/mime.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; -import { matchesMimeAllowlist, normalizeMime, expandExtensionShorthand } from "../../../src/media/mime.js"; +import { + matchesMimeAllowlist, + normalizeMime, + expandExtensionShorthand, +} from "../../../src/media/mime.js"; describe("matchesMimeAllowlist", () => { it("matches exact MIME types", () => { @@ -41,7 +45,9 @@ describe("matchesMimeAllowlist", () => { it("strips MIME parameters before matching", () => { expect(matchesMimeAllowlist("text/html; charset=utf-8", ["text/html"])).toBe(true); expect(matchesMimeAllowlist("text/plain; charset=iso-8859-1", ["text/"])).toBe(true); - expect(matchesMimeAllowlist("application/json; charset=utf-8", ["application/pdf"])).toBe(false); + expect(matchesMimeAllowlist("application/json; charset=utf-8", ["application/pdf"])).toBe( + false, + ); }); }); From 433a78a42e52dbf9fbc1cb9a376c85715355453e Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 8 May 2026 14:03:37 +0300 Subject: [PATCH 24/26] Fix adverserial review bugs --- .../src/components/AllowedTypesEditor.tsx | 2 +- .../src/api/handlers/validate-media-fields.ts | 22 +++++++++++++++++-- packages/core/src/api/schemas/media.ts | 9 +++++++- packages/core/src/api/schemas/schema.ts | 2 ++ .../content/media-field-validation.test.ts | 19 ++++++++++++++-- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/admin/src/components/AllowedTypesEditor.tsx b/packages/admin/src/components/AllowedTypesEditor.tsx index 76546c9b0..b024cdad2 100644 --- a/packages/admin/src/components/AllowedTypesEditor.tsx +++ b/packages/admin/src/components/AllowedTypesEditor.tsx @@ -35,7 +35,7 @@ const PRESETS: ReadonlyArray = [ { key: "audio", mimeTypes: ["audio/"] }, { key: "video", mimeTypes: ["video/"] }, { key: "captions", mimeTypes: ["text/vtt", "application/x-subrip"] }, - { key: "fonts", mimeTypes: ["font/", "application/font-woff"] }, + { key: "fonts", mimeTypes: ["font/"] }, ]; const EXTENSION_TO_MIME: Record = { diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index d1b2b726f..ce67fec55 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -99,7 +99,17 @@ export async function validateMediaFields( const provider = typeof ref.provider === "string" ? ref.provider : "local"; if (provider !== "local") { - if (typeof ref.mimeType !== "string") continue; + // mimeType is required on constrained external-provider refs. + // The value is trusted as-is — no server-side fetch to verify it. + if (typeof ref.mimeType !== "string") { + return { + success: false, + error: { + code: "INVALID_MIME_FOR_FIELD", + message: `Field '${field.slug}' requires a mimeType declaration for non-local media`, + }, + }; + } if (!matchesMimeAllowlist(ref.mimeType, field.allowedMimeTypes)) { return { success: false, @@ -112,7 +122,15 @@ export async function validateMediaFields( continue; } - if (typeof ref.id !== "string") continue; + if (typeof ref.id !== "string") { + return { + success: false, + error: { + code: "INVALID_MIME_FOR_FIELD", + message: `Field '${field.slug}' references media with an invalid id`, + }, + }; + } const mime = mimeById.get(ref.id); if (!mime) { diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index e23f643c2..9b0554536 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -42,6 +42,10 @@ export function formatFileSize(bytes: number): string { return `${Math.floor(bytes / 1024 / 1024)}MB`; } +// Matches a full MIME type (type/subtype) with an optional semicolon-delimited +// parameter section. Forbids CR/LF to prevent header injection. +const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i; + export function mediaUploadUrlBody(maxSize: number) { if (!Number.isFinite(maxSize) || maxSize <= 0) { throw new Error(`EmDash: maxUploadSize must be a positive finite number, got ${maxSize}`); @@ -49,7 +53,10 @@ export function mediaUploadUrlBody(maxSize: number) { return z .object({ filename: z.string().min(1, "filename is required"), - contentType: z.string().min(1, "contentType is required"), + contentType: z + .string() + .min(1, "contentType is required") + .regex(CONTENT_TYPE_RE, "Invalid content type"), size: z .number() .int() diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index ee18c0e2d..f057091a2 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -55,6 +55,8 @@ const fieldValidation = z .string() .regex(/^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i, "Invalid MIME type"), ) + .min(1, "allowedMimeTypes must not be empty — omit the field to allow all types") + .max(64, "allowedMimeTypes may contain at most 64 entries") .optional(), }) .optional(); diff --git a/packages/core/tests/integration/content/media-field-validation.test.ts b/packages/core/tests/integration/content/media-field-validation.test.ts index 2cfd902d9..f8515fee1 100644 --- a/packages/core/tests/integration/content/media-field-validation.test.ts +++ b/packages/core/tests/integration/content/media-field-validation.test.ts @@ -184,7 +184,7 @@ describeEachDialect("save-side media-field MIME validation", (dialect) => { expect(result.success).toBe(true); }); - it("skips MIME validation for external-provider ref with no mimeType field", async () => { + it("rejects external-provider ref with no mimeType when field is constrained", async () => { const result = await handleContentCreate(ctx.db, "posts", { slug: "p4c", data: { @@ -197,7 +197,22 @@ describeEachDialect("save-side media-field MIME validation", (dialect) => { }, }, }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); + }); + + it("rejects local-provider ref with non-string id when field is constrained", async () => { + const result = await handleContentCreate(ctx.db, "posts", { + slug: "p4d", + data: { + title: "p4d", + attachment: { id: 123, provider: "local", filename: "doc.pdf" }, + }, + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD"); }); it("file/image field without allowedMimeTypes is not validated", async () => { From 132e325eb1ee76928b49850b5b76aebcfa8ab573 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 8 May 2026 14:41:23 +0300 Subject: [PATCH 25/26] Commit fixes --- .../admin/src/components/MediaPickerModal.tsx | 4 +- packages/core/src/api/handlers/content.ts | 2 - .../core/src/api/handlers/media-allowlist.ts | 28 ++---- .../src/api/handlers/validate-media-fields.ts | 89 ++++++------------- packages/core/src/astro/routes/api/media.ts | 2 +- .../src/astro/routes/api/media/upload-url.ts | 2 +- .../core/src/database/repositories/media.ts | 37 ++++---- packages/core/src/media/mime.ts | 18 ++++ packages/core/src/schema/registry.ts | 13 ++- 9 files changed, 76 insertions(+), 119 deletions(-) diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index e57843562..c0cf194dd 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -74,9 +74,9 @@ export interface MediaPickerModalProps { * Defaults to "image" — set to "file" for generic file pickers. */ mediaKind?: "image" | "file"; - /** MIME allowlist — array of exact MIMEs or `type/` prefixes. Wired up fully in Task 11. */ + /** MIME allowlist — array of exact MIMEs or `type/` prefixes. */ mimeTypeFilters?: string[]; - /** `_emdash_fields` row id for server-side MIME widening. Wired up fully in Task 11. */ + /** `_emdash_fields` row id for server-side MIME widening. */ fieldId?: string; } diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 7fd13ed8e..d7d5b5799 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -445,7 +445,6 @@ export async function handleContentCreate( }; } - // Validate file/image fields against their allowedMimeTypes constraints const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; @@ -596,7 +595,6 @@ export async function handleContentUpdate( }; } - // Validate file/image fields against their allowedMimeTypes constraints if (body.data) { const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; diff --git a/packages/core/src/api/handlers/media-allowlist.ts b/packages/core/src/api/handlers/media-allowlist.ts index c6d8cbb61..ca5cd4894 100644 --- a/packages/core/src/api/handlers/media-allowlist.ts +++ b/packages/core/src/api/handlers/media-allowlist.ts @@ -1,13 +1,7 @@ -import { hasPermission } from "@emdash-cms/auth"; -import type { RoleLevel } from "@emdash-cms/auth"; import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; - -interface UserLike { - id: string; - role: RoleLevel; -} +import { parseAllowedMimeTypes } from "../../media/mime.js"; /** * MIME types allowed for upload by default (when no field-specific list @@ -27,30 +21,20 @@ export const GLOBAL_UPLOAD_ALLOWLIST: readonly string[] = [ * Returns the field's `allowedMimeTypes` list when the field exists, is of * type "file" or "image", and has a non-empty list configured. Returns null * in all other cases — callers should fall back to GLOBAL_UPLOAD_ALLOWLIST. + * + * Authentication is the caller's responsibility (the upload routes already + * gate on `media:upload`). */ export async function resolveFieldAllowlist( db: Kysely, fieldId: string, - user: UserLike | null | undefined, ): Promise { - if (!hasPermission(user, "content:create")) return null; const row = await db .selectFrom("_emdash_fields") .select(["type", "validation"]) .where("id", "=", fieldId) + .where("type", "in", ["file", "image"]) .executeTakeFirst(); - if (!row) return null; - if (row.type !== "file" && row.type !== "image") return null; - if (!row.validation) return null; - - try { - const parsed: unknown = JSON.parse(row.validation); - if (typeof parsed !== "object" || parsed === null) return null; - const list = (parsed as { allowedMimeTypes?: string[] }).allowedMimeTypes; - if (!list || list.length === 0) return null; - return list; - } catch { - return null; - } + return row ? parseAllowedMimeTypes(row.validation) : null; } diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index ce67fec55..31f3860ef 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -1,7 +1,7 @@ import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; -import { matchesMimeAllowlist } from "../../media/mime.js"; +import { matchesMimeAllowlist, parseAllowedMimeTypes } from "../../media/mime.js"; import { requestCached } from "../../request-cache.js"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import type { ApiResult } from "../types.js"; @@ -24,6 +24,10 @@ function asMediaRef(value: unknown): MediaRefValue | null { return value as MediaRefValue; } +function fail(message: string): ApiResult { + return { success: false, error: { code: "INVALID_MIME_FOR_FIELD", message } }; +} + async function loadMediaFieldsForCollection( db: Kysely, collectionSlug: string, @@ -33,25 +37,14 @@ async function loadMediaFieldsForCollection( .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"]) .where("_emdash_collections.slug", "=", collectionSlug) - .where((eb) => - eb.or([eb("_emdash_fields.type", "=", "file"), eb("_emdash_fields.type", "=", "image")]), - ) + .where("_emdash_fields.type", "in", ["file", "image"]) .execute(); const out: FieldRow[] = []; for (const row of rows) { - if (!row.validation) continue; - try { - const parsed: unknown = JSON.parse(row.validation); - if (typeof parsed !== "object" || parsed === null) continue; - const list = (parsed as { allowedMimeTypes?: string[] }).allowedMimeTypes; - if (!list || list.length === 0) continue; - out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list }); - } catch { - console.warn( - `[emdash] malformed validation JSON for field '${row.slug}' — skipping MIME check`, - ); - } + const list = parseAllowedMimeTypes(row.validation); + if (!list) continue; + out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list }); } return out; } @@ -98,59 +91,27 @@ export async function validateMediaFields( if (!ref) continue; const provider = typeof ref.provider === "string" ? ref.provider : "local"; - if (provider !== "local") { - // mimeType is required on constrained external-provider refs. - // The value is trusted as-is — no server-side fetch to verify it. - if (typeof ref.mimeType !== "string") { - return { - success: false, - error: { - code: "INVALID_MIME_FOR_FIELD", - message: `Field '${field.slug}' requires a mimeType declaration for non-local media`, - }, - }; + + // External providers carry mimeType in the ref; trust it as-is. + // Local media: look up the stored mimeType by id. + let mime: string | undefined; + if (provider === "local") { + if (typeof ref.id !== "string") { + return fail(`Field '${field.slug}' references media with an invalid id`); } - if (!matchesMimeAllowlist(ref.mimeType, field.allowedMimeTypes)) { - return { - success: false, - error: { - code: "INVALID_MIME_FOR_FIELD", - message: `Field '${field.slug}' does not accept ${ref.mimeType}`, - }, - }; + mime = mimeById.get(ref.id); + if (!mime) { + return fail(`Field '${field.slug}' references media with unknown MIME type`); } - continue; - } - - if (typeof ref.id !== "string") { - return { - success: false, - error: { - code: "INVALID_MIME_FOR_FIELD", - message: `Field '${field.slug}' references media with an invalid id`, - }, - }; - } - const mime = mimeById.get(ref.id); - - if (!mime) { - return { - success: false, - error: { - code: "INVALID_MIME_FOR_FIELD", - message: `Field '${field.slug}' references media with unknown MIME type`, - }, - }; + } else { + if (typeof ref.mimeType !== "string") { + return fail(`Field '${field.slug}' requires a mimeType declaration for non-local media`); + } + mime = ref.mimeType; } if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) { - return { - success: false, - error: { - code: "INVALID_MIME_FOR_FIELD", - message: `Field '${field.slug}' does not accept ${mime}`, - }, - }; + return fail(`Field '${field.slug}' does not accept ${mime}`); } } diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index d42192630..4ed579e4e 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -113,7 +113,7 @@ export const POST: APIRoute = async ({ request, locals }) => { const fieldId = typeof fieldIdEntry === "string" && fieldIdEntry.length > 0 ? fieldIdEntry : null; - const fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId, user) : null; + const fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId) : null; const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST]; if (!matchesMimeAllowlist(file.type, allowlist)) { diff --git a/packages/core/src/astro/routes/api/media/upload-url.ts b/packages/core/src/astro/routes/api/media/upload-url.ts index 469e2674c..da690a370 100644 --- a/packages/core/src/astro/routes/api/media/upload-url.ts +++ b/packages/core/src/astro/routes/api/media/upload-url.ts @@ -74,7 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Validate content type (field-aware widening) const fieldAllowlist = body.fieldId - ? await resolveFieldAllowlist(emdash.db, body.fieldId, user) + ? await resolveFieldAllowlist(emdash.db, body.fieldId) : null; const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST]; diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index 8fec6a27e..6c29558d7 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -1,4 +1,4 @@ -import { sql, type Kysely, type SqlBool } from "kysely"; +import { sql, type ExpressionBuilder, type Kysely, type SqlBool } from "kysely"; import { ulid } from "ulidx"; import type { Database, MediaRow } from "../types.js"; @@ -24,6 +24,21 @@ function normalizeMimeFilter(input?: string | readonly string[]): string[] { ); } +/** + * Build a WHERE clause that matches `mime_type` against any of the given + * filter entries — exact equality for full MIMEs, LIKE prefix for entries + * ending in "/". + */ +function mimeMatchExpr(eb: ExpressionBuilder, filters: string[]) { + return eb.or( + filters.map((entry) => + entry.endsWith("/") + ? sql`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'` + : eb("mime_type", "=", entry), + ), + ); +} + export type MediaStatus = "pending" | "ready" | "failed"; export interface MediaItem { @@ -232,15 +247,7 @@ export class MediaRepository { const mimeFilters = normalizeMimeFilter(options.mimeType); if (mimeFilters.length > 0) { - query = query.where((eb) => - eb.or( - mimeFilters.map((entry) => - entry.endsWith("/") - ? sql`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'` - : eb("mime_type", "=", entry), - ), - ), - ); + query = query.where((eb) => mimeMatchExpr(eb, mimeFilters)); } // Default to only showing ready items @@ -304,15 +311,7 @@ export class MediaRepository { let query = this.db.selectFrom("media").select((eb) => eb.fn.count("id").as("count")); if (filters.length > 0) { - query = query.where((eb) => - eb.or( - filters.map((entry) => - entry.endsWith("/") - ? sql`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'` - : eb("mime_type", "=", entry), - ), - ), - ); + query = query.where((eb) => mimeMatchExpr(eb, filters)); } const result = await query.executeTakeFirst(); diff --git a/packages/core/src/media/mime.ts b/packages/core/src/media/mime.ts index 514f1be3b..40294c6e0 100644 --- a/packages/core/src/media/mime.ts +++ b/packages/core/src/media/mime.ts @@ -53,3 +53,21 @@ export function expandExtensionShorthand(entry: string): string | null { } return null; } + +/** + * Extract the `allowedMimeTypes` list from a `_emdash_fields.validation` row + * (raw JSON string). Returns null when the value is missing, malformed, or the + * list is empty — callers treat that as "no field-specific constraint". + */ +export function parseAllowedMimeTypes(rawValidation: string | null | undefined): string[] | null { + if (!rawValidation) return null; + try { + const parsed: unknown = JSON.parse(rawValidation); + if (typeof parsed !== "object" || parsed === null) return null; + const list = (parsed as { allowedMimeTypes?: unknown }).allowedMimeTypes; + if (!Array.isArray(list) || list.length === 0) return null; + return list.filter((entry): entry is string => typeof entry === "string"); + } catch { + return null; + } +} diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 700bef581..27fe1ca68 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -526,6 +526,10 @@ export class SchemaRegistry { ); } + // `input.validation === undefined` means "no change" (keep existing); + // an explicit `null` clears the column. + const nextValidation = input.validation === undefined ? field.validation : input.validation; + return withTransaction(this.db, async (trx) => { await trx .updateTable("_emdash_fields") @@ -550,14 +554,7 @@ export class SchemaRegistry { : field.defaultValue !== undefined ? JSON.stringify(field.defaultValue) : null, - validation: - input.validation === undefined - ? field.validation - ? JSON.stringify(field.validation) - : null - : input.validation - ? JSON.stringify(input.validation) - : null, + validation: nextValidation ? JSON.stringify(nextValidation) : null, widget: input.widget ?? field.widget ?? null, options: input.options ? JSON.stringify(input.options) From 08d8047a0f027135bf5601fedef3a7080ad8de0d Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 8 May 2026 15:16:46 +0300 Subject: [PATCH 26/26] Address PR review --- .changeset/media-allowed-types.md | 2 + .../src/components/AllowedTypesEditor.tsx | 31 +---------- .../admin/src/components/MediaPickerModal.tsx | 18 ++++++- packages/admin/src/lib/mime-utils.ts | 53 +++++++++++++++++++ .../src/api/handlers/validate-media-fields.ts | 6 +++ packages/core/src/media/mime.ts | 6 ++- packages/core/src/schema/types.ts | 2 +- 7 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 packages/admin/src/lib/mime-utils.ts diff --git a/.changeset/media-allowed-types.md b/.changeset/media-allowed-types.md index e1b528bda..da7967dc6 100644 --- a/.changeset/media-allowed-types.md +++ b/.changeset/media-allowed-types.md @@ -6,3 +6,5 @@ Adds per-field allowed MIME types for `file` and `image` fields. Field-level `allowedTypes` is now honored end-to-end: it filters the media picker, widens upload acceptance for that field (so e.g. a zip-only field can accept zip uploads even though the global allowlist excludes them), and validates referenced media against the destination field on content save. The schema editor in admin gains an "Allowed types" control with curated presets and freeform entry. Behavior change: the `image` builder's `allowedTypes` option was previously accepted but read by nothing. It is now load-bearing — a code-first schema that already passed `allowedTypes` (e.g. `["image/png"]`) will now actually narrow the picker and gate uploads. Most users will see no change; if you set this option intending the old (silent) behavior, drop it. + +Behavior change: updating a field via the admin schema editor now explicitly clears its validation when the form contains no validation settings, instead of leaving an existing `validation` value intact. This only affects fields with pre-existing validation that is not expressible in the editor UI. diff --git a/packages/admin/src/components/AllowedTypesEditor.tsx b/packages/admin/src/components/AllowedTypesEditor.tsx index b024cdad2..9b18bd5dd 100644 --- a/packages/admin/src/components/AllowedTypesEditor.tsx +++ b/packages/admin/src/components/AllowedTypesEditor.tsx @@ -3,6 +3,7 @@ import { useLingui } from "@lingui/react/macro"; import { Plus, X } from "@phosphor-icons/react"; import * as React from "react"; +import { EXTENSION_TO_MIME, VALID_MIME_RE } from "../lib/mime-utils.js"; import { cn } from "../lib/utils"; interface Preset { @@ -38,36 +39,6 @@ const PRESETS: ReadonlyArray = [ { key: "fonts", mimeTypes: ["font/"] }, ]; -const EXTENSION_TO_MIME: Record = { - ".pdf": "application/pdf", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".mp3": "audio/mpeg", - ".wav": "audio/wav", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".zip": "application/zip", - ".tar": "application/x-tar", - ".gz": "application/gzip", - ".csv": "text/csv", - ".doc": "application/msword", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xls": "application/vnd.ms-excel", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".txt": "text/plain", - ".rtf": "application/rtf", - ".vtt": "text/vtt", - ".srt": "application/x-subrip", - ".woff": "font/woff", - ".woff2": "font/woff2", -}; - -const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i; - function expandShorthand(entry: string): string | null { const trimmed = entry.trim(); if (!trimmed) return null; diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index c0cf194dd..176b734c9 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -26,6 +26,7 @@ import { type MediaProviderItem, } from "../lib/api"; import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils"; +import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js"; import { cn } from "../lib/utils"; import { DialogError } from "./DialogError.js"; @@ -346,11 +347,26 @@ export function MediaPickerModal({ setUrlError(null); try { + const sniffedMime = mimeFromUrl(url) ?? "image/unknown"; + + // Pre-validate against the field's allowlist so the user sees the error + // here rather than at content-save time (where it becomes INVALID_MIME_FOR_FIELD). + if (sniffedMime === "image/unknown" && filters && filters.length > 0) { + setUrlError( + t`Cannot determine MIME type from URL. Use a URL ending in a recognized image extension (e.g. .jpg, .png, .webp).`, + ); + return; + } + if (filters && filters.length > 0 && !matchesMimeAllowlist(sniffedMime, filters)) { + setUrlError(t`This field does not accept ${sniffedMime} files.`); + return; + } + const dimensions = await probeImageDimensions(url.href, t`Failed to load image`); const externalItem: MediaItem = { id: "", filename: url.pathname.split("/").pop() || "external-image", - mimeType: "image/unknown", + mimeType: sniffedMime, url: url.href, provider: "external-url", size: 0, diff --git a/packages/admin/src/lib/mime-utils.ts b/packages/admin/src/lib/mime-utils.ts new file mode 100644 index 000000000..96fdae0ca --- /dev/null +++ b/packages/admin/src/lib/mime-utils.ts @@ -0,0 +1,53 @@ +export const EXTENSION_TO_MIME: Readonly> = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".txt": "text/plain", + ".rtf": "application/rtf", + ".vtt": "text/vtt", + ".srt": "application/x-subrip", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +export const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i; + +/** Mirror of core matchesMimeAllowlist — kept in sync for client-side pre-validation. */ +export function matchesMimeAllowlist(mime: string, allowList: readonly string[]): boolean { + const normalized = mime.split(";")[0]!.trim().toLowerCase(); + for (const entry of allowList) { + if (!entry || !entry.includes("/")) continue; + const normalizedEntry = entry.split(";")[0]!.trim().toLowerCase(); + if (normalizedEntry.endsWith("/")) { + if (normalized.startsWith(normalizedEntry)) return true; + } else if (normalized === normalizedEntry) { + return true; + } + } + return false; +} + +/** Try to resolve a MIME type from a URL's file extension. Returns null on failure. */ +export function mimeFromUrl(url: URL): string | null { + const lastSegment = url.pathname.split("/").pop() ?? ""; + const dotIdx = lastSegment.lastIndexOf("."); + if (dotIdx === -1) return null; + const ext = lastSegment.slice(dotIdx).toLowerCase(); + return EXTENSION_TO_MIME[ext] ?? null; +} diff --git a/packages/core/src/api/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts index 31f3860ef..7264152fa 100644 --- a/packages/core/src/api/handlers/validate-media-fields.ts +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -54,6 +54,9 @@ export async function validateMediaFields( collectionSlug: string, data: Record, ): Promise> { + // Cache is keyed on slug only. If a handler creates/modifies a field and + // then writes content in the same request (e.g. bulk import), the cached + // list will be stale for that request. This is an edge case in normal use. const fields = await requestCached(`mediaFields:${collectionSlug}`, () => loadMediaFieldsForCollection(db, collectionSlug), ); @@ -107,6 +110,9 @@ export async function validateMediaFields( if (typeof ref.mimeType !== "string") { return fail(`Field '${field.slug}' requires a mimeType declaration for non-local media`); } + // TODO: long-term, consider a server-side HEAD probe or provider-vouched + // MIMEs for non-local refs; for now the constraint is only as strong as + // the client that constructed the ref. mime = ref.mimeType; } diff --git a/packages/core/src/media/mime.ts b/packages/core/src/media/mime.ts index 40294c6e0..fa926211f 100644 --- a/packages/core/src/media/mime.ts +++ b/packages/core/src/media/mime.ts @@ -7,7 +7,7 @@ export function matchesMimeAllowlist(mime: string, allowList: readonly string[]) for (const entry of allowList) { if (!entry || !entry.includes("/")) continue; const normalizedEntry = normalizeMime(entry); - if (entry.endsWith("/")) { + if (normalizedEntry.endsWith("/")) { if (normalized.startsWith(normalizedEntry)) return true; } else if (normalized === normalizedEntry) { return true; @@ -44,10 +44,12 @@ export const EXTENSION_TO_MIME: Readonly> = { ".woff2": "font/woff2", }; +const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i; + export function expandExtensionShorthand(entry: string): string | null { const trimmed = entry.trim(); if (!trimmed) return null; - if (trimmed.includes("/")) return trimmed; + if (trimmed.includes("/")) return VALID_MIME_RE.test(trimmed) ? trimmed : null; if (trimmed.startsWith(".")) { return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null; } diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index e3df31b91..ec16698fc 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -239,7 +239,7 @@ export interface CreateFieldInput { required?: boolean; unique?: boolean; defaultValue?: unknown; - validation?: FieldValidation; + validation?: FieldValidation | null; widget?: string; options?: FieldWidgetOptions; sortOrder?: number;