diff --git a/.changeset/media-allowed-types.md b/.changeset/media-allowed-types.md new file mode 100644 index 000000000..da7967dc6 --- /dev/null +++ b/.changeset/media-allowed-types.md @@ -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. diff --git a/packages/admin/src/components/AllowedTypesEditor.tsx b/packages/admin/src/components/AllowedTypesEditor.tsx new file mode 100644 index 000000000..9b18bd5dd --- /dev/null +++ b/packages/admin/src/components/AllowedTypesEditor.tsx @@ -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 = [ + { 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(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/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 8f77bc8f8..0ccbdcf67 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -84,6 +84,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}`} diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 6f6573c2e..6d95063e6 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -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 @@ -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 ?? [], }; } 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" || @@ -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, }; onSave(input); @@ -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/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index 8c710bb16..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"; @@ -35,6 +36,26 @@ 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; + const normalizedMime = mime.toLowerCase(); + for (const entry of filters) { + if (!entry || !entry.includes("/")) continue; + const normalizedEntry = entry.toLowerCase(); + if (normalizedEntry.endsWith("/")) { + if (normalizedMime.startsWith(normalizedEntry)) return true; + } else if (normalizedMime === normalizedEntry) { + return true; + } + } + return false; +} + export interface MediaPickerModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -54,6 +75,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. */ + mimeTypeFilters?: string[]; + /** `_emdash_fields` row id for server-side MIME widening. */ + fieldId?: string; } /** @@ -80,12 +105,23 @@ 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 !== undefined) + return mimeTypeFilters.length > 0 ? mimeTypeFilters : undefined; + 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` @@ -145,10 +181,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", @@ -156,10 +192,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, }), @@ -172,7 +208,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 }); @@ -208,7 +244,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 { @@ -244,11 +280,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; @@ -312,12 +347,28 @@ 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, width: dimensions.width, height: dimensions.height, @@ -491,7 +542,11 @@ export function MediaPickerModal({ (f.endsWith("/") ? f + "*" : f)).join(",") + : undefined + } className="sr-only" onChange={handleFileSelect} aria-label={t`Upload file`} diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index eca38c8f2..151ddc5a7 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -61,6 +61,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/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 83fdbcff4..94b11f79c 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -38,12 +38,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); @@ -66,7 +69,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", @@ -75,6 +81,7 @@ async function getUploadUrl(file: File): Promise { filename: file.name, contentType: file.type, size: file.size, + ...(opts?.fieldId ? { fieldId: opts.fieldId } : {}), }), }); @@ -150,7 +157,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); @@ -159,6 +166,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", @@ -174,13 +182,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 @@ -277,14 +285,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); diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index c294df23c..1b991befa 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -62,6 +62,7 @@ export interface SchemaField { maxLength?: number; pattern?: string; options?: string[]; + allowedMimeTypes?: string[]; }; widget?: string; options?: Record; @@ -113,7 +114,8 @@ export interface CreateFieldInput { maxLength?: number; pattern?: string; options?: string[]; - }; + allowedMimeTypes?: string[]; + } | null; widget?: string; options?: Record; } @@ -131,7 +133,8 @@ export interface UpdateFieldInput { maxLength?: number; pattern?: string; options?: string[]; - }; + allowedMimeTypes?: string[]; + } | null; widget?: string; options?: Record; sortOrder?: number; 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/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(); diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 71fe4504b..d7d5b5799 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,9 @@ export async function handleContentCreate( }; } + 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 +595,11 @@ export async function handleContentUpdate( }; } + 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/media-allowlist.ts b/packages/core/src/api/handlers/media-allowlist.ts new file mode 100644 index 000000000..ca5cd4894 --- /dev/null +++ b/packages/core/src/api/handlers/media-allowlist.ts @@ -0,0 +1,40 @@ +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; +import { parseAllowedMimeTypes } from "../../media/mime.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. + * + * Authentication is the caller's responsibility (the upload routes already + * gate on `media:upload`). + */ +export async function resolveFieldAllowlist( + db: Kysely, + fieldId: string, +): Promise { + const row = await db + .selectFrom("_emdash_fields") + .select(["type", "validation"]) + .where("id", "=", fieldId) + .where("type", "in", ["file", "image"]) + .executeTakeFirst(); + + return row ? parseAllowedMimeTypes(row.validation) : null; +} 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/handlers/validate-media-fields.ts b/packages/core/src/api/handlers/validate-media-fields.ts new file mode 100644 index 000000000..7264152fa --- /dev/null +++ b/packages/core/src/api/handlers/validate-media-fields.ts @@ -0,0 +1,125 @@ +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.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"; + +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" || Array.isArray(value)) return 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, +): Promise { + const rows = await db + .selectFrom("_emdash_fields") + .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("_emdash_fields.type", "in", ["file", "image"]) + .execute(); + + const out: FieldRow[] = []; + for (const row of rows) { + const list = parseAllowedMimeTypes(row.validation); + if (!list) continue; + out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list }); + } + return out; +} + +export async function validateMediaFields( + db: Kysely, + 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), + ); + 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"; + + // 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`); + } + mime = mimeById.get(ref.id); + if (!mime) { + return fail(`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`); + } + // 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; + } + + if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) { + return fail(`Field '${field.slug}' does not accept ${mime}`); + } + } + + return { success: true, data: true }; +} diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index b81968b02..9b0554536 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" }); @@ -30,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}`); @@ -37,13 +53,17 @@ 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() .positive() .max(maxSize, `File size must not exceed ${formatFileSize(maxSize)}`), contentHash: z.string().optional(), + fieldId: z.string().optional(), }) .meta({ id: "MediaUploadUrlBody" }); } @@ -59,7 +79,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/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index df15a10b6..f057091a2 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -49,6 +49,15 @@ 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() + .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(); @@ -92,7 +101,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(), @@ -107,7 +116,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 f94705ecb..4ed579e4e 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, normalizeMime } 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); } @@ -174,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 c7ab7b424..da690a370 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, normalizeMime } 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); } @@ -100,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/astro/types.ts b/packages/core/src/astro/types.ts index c15497b96..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; } >; } @@ -292,7 +296,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/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index e88180ca3..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"; @@ -10,6 +10,35 @@ 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) + .map((entry) => + entry.endsWith("/") ? entry.toLowerCase() : entry.split(";")[0]!.trim().toLowerCase(), + ); +} + +/** + * 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 { @@ -49,7 +78,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 +245,9 @@ 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) => mimeMatchExpr(eb, mimeFilters)); } // Default to only showing ready items @@ -276,12 +306,12 @@ 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) => mimeMatchExpr(eb, filters)); } const result = await query.executeTakeFirst(); diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 4a216e776..9edd2226c 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; } > = {}; @@ -1296,6 +1298,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.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,8 +1317,12 @@ export class EmDashRuntime { })); } // Include full validation for repeater fields (subFields, minItems, maxItems) - if (field.type === "repeater" && field.validation) { - (entry as Record).validation = field.validation; + // and for file/image fields (allowedMimeTypes). + if ( + (field.type === "repeater" || field.type === "file" || field.type === "image") && + field.validation + ) { + entry.validation = { ...field.validation }; } fields[field.slug] = entry; } @@ -1980,7 +1989,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/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/src/media/mime.ts b/packages/core/src/media/mime.ts new file mode 100644 index 000000000..fa926211f --- /dev/null +++ b/packages/core/src/media/mime.ts @@ -0,0 +1,75 @@ +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 (normalizedEntry.endsWith("/")) { + if (normalized.startsWith(normalizedEntry)) return true; + } else if (normalized === normalizedEntry) { + 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", +}; + +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 VALID_MIME_RE.test(trimmed) ? trimmed : null; + if (trimmed.startsWith(".")) { + return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? 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 8c88fe068..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,11 +554,7 @@ 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: nextValidation ? JSON.stringify(nextValidation) : 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 a607ce3c5..ec16698fc 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[]; } /** @@ -238,7 +239,7 @@ export interface CreateFieldInput { required?: boolean; unique?: boolean; defaultValue?: unknown; - validation?: FieldValidation; + validation?: FieldValidation | null; widget?: string; options?: FieldWidgetOptions; sortOrder?: number; @@ -256,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/astro/media-upload-widening.test.ts b/packages/core/tests/integration/astro/media-upload-widening.test.ts new file mode 100644 index 000000000..8a3b37f18 --- /dev/null +++ b/packages/core/tests/integration/astro/media-upload-widening.test.ts @@ -0,0 +1,411 @@ +/** + * 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 { 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"; + +// --------------------------------------------------------------------------- +// 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"); + }); +}); + +// --------------------------------------------------------------------------- +// 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"); + }); +}); 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..f8515fee1 --- /dev/null +++ b/packages/core/tests/integration/content/media-field-validation.test.ts @@ -0,0 +1,275 @@ +import { ulid } from "ulidx"; +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"; + +describeEachDialect("save-side media-field MIME validation", (dialect) => { + let ctx: DialectTestContext; + let pdfMediaId: string; + let zipMediaId: string; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + + // 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("rejects external-provider ref when mimeType is present and does not match allowlist", 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"); + }); + + 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("rejects external-provider ref with no mimeType when field is constrained", 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(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 () => { + // 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"); + }); +}); 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([]); + }); +}); 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", + ]); + }); +}); 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); 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(); + }); +}); 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..4fd247a92 --- /dev/null +++ b/packages/core/tests/unit/media/mime.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; + +import { + matchesMimeAllowlist, + normalizeMime, + 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); + }); + + 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", () => { + 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(); + }); +});