Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
09445e8
feat(core): add matchesMimeAllowlist helper for per-field MIME valida…
MA2153 May 7, 2026
5ef22bd
feat(core): add allowedMimeTypes to FieldValidation
MA2153 May 7, 2026
5e1ab0c
feat(core): file/image builders write allowedMimeTypes into validatio…
MA2153 May 7, 2026
4b10634
feat(core): MediaRepository.findMany accepts an array of MIME prefixe…
MA2153 May 7, 2026
1d591c7
feat(core): media list endpoints accept comma-separated mimeType
MA2153 May 7, 2026
5734d97
feat(core): widen POST /api/media allowlist when fieldId resolves to …
MA2153 May 7, 2026
7903116
feat(core): widen upload-url allowlist via fieldId
MA2153 May 7, 2026
0172543
feat(core): validate file/image fields against allowedMimeTypes on co…
MA2153 May 7, 2026
4891453
fix(core): describeEachDialect, JOIN query, and missing test cases in…
MA2153 May 7, 2026
200b4e9
feat(admin): allowed-types control for file/image fields in schema ed…
MA2153 May 7, 2026
61b9e96
fix(admin): merge duplicate import and add AllowedTypesEditor FieldEd…
MA2153 May 7, 2026
fe148fc
feat(admin): forward allowedMimeTypes and fieldId from schema to rend…
MA2153 May 7, 2026
86c46a6
fix(core): include field id and validation in admin manifest for file…
MA2153 May 7, 2026
035491c
fix(core): update ManifestCollection type with field id and validatio…
MA2153 May 7, 2026
59bd04b
fix(admin): stub mimeTypeFilters and fieldId on MediaPickerModal for …
MA2153 May 7, 2026
992ac2d
feat(admin): MediaPickerModal accepts mimeTypeFilters[] and fieldId (…
MA2153 May 7, 2026
9148a49
fix(admin): translate type/ prefix to type/* in input accept attribute
MA2153 May 7, 2026
d2313de
chore: add changeset for per-field allowed media types
MA2153 May 7, 2026
df139ee
fix(lint): resolve unsafe type assertions introduced by this branch
MA2153 May 7, 2026
879c3db
Delete redundant changeset
MA2153 May 7, 2026
437827b
Fix allowed types being dropped from schema
MA2153 May 7, 2026
081ddde
Merge branch 'main' into field-allowed-types
MA2153 May 8, 2026
ed4b03c
Address adversarial reviewer feedback
MA2153 May 8, 2026
dd718a7
Address second adverserial review
MA2153 May 8, 2026
433a78a
Fix adverserial review bugs
MA2153 May 8, 2026
b2a53e2
Merge branch 'main' into field-allowed-types
ascorbic May 8, 2026
9604e40
Merge branch 'main' into field-allowed-types
MA2153 May 8, 2026
e1c14a1
Merge branch 'main' into field-allowed-types
MA2153 May 8, 2026
132e325
Commit fixes
MA2153 May 8, 2026
08d8047
Address PR review
MA2153 May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/media-allowed-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"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.

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.
177 changes: 177 additions & 0 deletions packages/admin/src/components/AllowedTypesEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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 { EXTENSION_TO_MIME, VALID_MIME_RE } from "../lib/mime-utils.js";
import { cn } from "../lib/utils";

interface Preset {
key: string;
mimeTypes: string[];
}

const PRESETS: ReadonlyArray<Preset> = [
{ 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/"] },
];

function expandShorthand(entry: string): string | null {
const trimmed = entry.trim();
if (!trimmed) return null;
if (trimmed.includes("/")) return VALID_MIME_RE.test(trimmed) ? trimmed : null;
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<string | null>(null);

const presetLabels: Record<string, string> = {
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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

togglePreset toggles by exact MIME-string membership in value. If a preset's MIME (e.g. "application/pdf" from the Documents preset) is also already present from the PDF preset toggle, deselecting Documents will silently strip PDF too. Less of a bug, more of a UX quirk — toggling a multi-MIME preset has hidden interactions with adjacent presets that share entries. Worth at least a comment, or treating presets as additive-only with a single "clear" affordance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair UX observation. The toggle-by-membership behavior is intentional — it lets users compose their own sets from multiple presets. The hidden interaction (Documents includes PDF, so toggling both means deselecting Documents also removes PDF) is a consequence of that design. Will leave as-is for now; adding a comment in the code.

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 (
<div className="space-y-3">
<Label>{t`Allowed types`}</Label>
<p className="text-xs text-kumo-subtle">
{value.length === 0
? t`Any media type allowed (subject to global limits).`
: t`Only the listed MIME types will be accepted for this field.`}
</p>

<div className="flex flex-wrap gap-1.5">
{PRESETS.map((preset) => {
const allIncluded = preset.mimeTypes.every((m) => set.has(m));
return (
<button
key={preset.key}
type="button"
onClick={() => togglePreset(preset)}
aria-pressed={allIncluded}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium transition-colors",
allIncluded
? "bg-kumo-brand text-white"
: "bg-kumo-tint text-kumo-subtle hover:bg-kumo-tint/80",
)}
>
{presetLabels[preset.key]}
</button>
);
})}
</div>

{value.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{value.map((entry) => (
<li
key={entry}
className="flex items-center gap-1 bg-kumo-tint rounded px-2 py-1 text-xs"
>
<code>{entry}</code>
<Button
type="button"
shape="square"
variant="ghost"
className="h-5 w-5"
onClick={() => removeEntry(entry)}
aria-label={t`Remove ${entry}`}
>
<X className="h-3 w-3" />
</Button>
</li>
))}
</ul>
)}

<div className="flex gap-2">
<Input
value={draft}
onChange={(e) => {
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();
}
}}
/>
<Button type="button" icon={Plus} onClick={addDraft} disabled={!draft.trim()}>
{t`Add`}
</Button>
</div>
{warning && <p className="text-xs text-kumo-danger">{warning}</p>}
</div>
);
}
37 changes: 34 additions & 3 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { TranslationsPanel } from "./TranslationsPanel.js";
const ROLE_EDITOR = 40;

export interface FieldDescriptor {
id?: string;
kind: string;
label?: string;
required?: boolean;
Expand Down Expand Up @@ -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}
/>
);
}
Expand All @@ -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}
/>
);
}
Expand Down Expand Up @@ -1559,6 +1572,8 @@ interface ImageFieldRendererProps {
value: ImageFieldValue | string | undefined;
onChange: (value: ImageFieldValue | null) => void;
required?: boolean;
allowedMimeTypes?: string[];
fieldId?: string;
}

function ImageFieldRenderer({
Expand All @@ -1568,6 +1583,8 @@ function ImageFieldRenderer({
value,
onChange,
required,
allowedMimeTypes,
fieldId,
}: ImageFieldRendererProps) {
const { t } = useLingui();
const [pickerOpen, setPickerOpen] = React.useState(false);
Expand Down Expand Up @@ -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 && <p className="text-xs text-kumo-subtle mt-1">{description}</p>}
Expand Down Expand Up @@ -1675,6 +1695,8 @@ interface FileFieldRendererProps {
value: FileFieldValue | undefined;
onChange: (value: FileFieldValue | null) => void;
required?: boolean;
allowedMimeTypes?: string[];
fieldId?: string;
}

/**
Expand All @@ -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);

Expand Down Expand Up @@ -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}`}
Expand Down
22 changes: 20 additions & 2 deletions packages/admin/src/components/FieldEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ 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";
import { cn } from "../lib/utils";
import { AllowedTypesEditor } from "./AllowedTypesEditor";

// ============================================================================
// Constants
Expand Down Expand Up @@ -75,6 +76,7 @@ interface FieldFormState {
subFields: RepeaterSubFieldState[];
minItems: string;
maxItems: string;
allowedMimeTypes: string[];
}

function getInitialFormState(field?: SchemaField): FieldFormState {
Expand All @@ -98,6 +100,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
: [],
minItems: (field.validation as Record<string, unknown>)?.minItems?.toString() ?? "",
maxItems: (field.validation as Record<string, unknown>)?.maxItems?.toString() ?? "",
allowedMimeTypes: field.validation?.allowedMimeTypes ?? [],
};
}
return {
Expand All @@ -117,6 +120,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
subFields: [],
minItems: "",
maxItems: "",
allowedMimeTypes: [],
};
}

Expand Down Expand Up @@ -300,6 +304,13 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
(validation as Record<string, unknown>).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" ||
Expand All @@ -315,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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching the empty-validation case from undefined to null is a deliberate behavior change: on update it now actively clears any existing validation rather than leaving it intact. That's the right fix (you couldn't previously remove validation), but it's a subtle break for any existing custom code path that posted a partial update without validation and expected the server to merge. Worth a one-liner in the changeset noting that updating a field via the admin now clears its validation if the form has none, instead of preserving.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note to the changeset.

};

onSave(input);
Expand Down Expand Up @@ -636,6 +647,13 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
</div>
</div>
)}

{(selectedType === "file" || selectedType === "image") && (
<AllowedTypesEditor
value={formState.allowedMimeTypes}
onChange={(next) => setField("allowedMimeTypes", next)}
/>
)}
</div>
)}

Expand Down
Loading
Loading