diff --git a/.gitignore b/.gitignore index f7eea70d2..a4a3c6e12 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ content-collections.d.ts .pi .revue/comments.json +.revue/session.json .agents/skills/typebot-customer-support .agents/skills/typebot-discord-bot diff --git a/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx b/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx index 5bc5fb494..3e791c990 100644 --- a/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx +++ b/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx @@ -1,9 +1,11 @@ import { useTranslate } from "@tolgee/react"; import { defaultFileInputOptions, + fileCaptureModeOptions, fileVisibilityOptions, } from "@typebot.io/blocks-inputs/file/constants"; import type { FileInputBlock } from "@typebot.io/blocks-inputs/file/schema"; +import { isImageFileInput } from "@typebot.io/lib/isImageFileInput"; import { Accordion } from "@typebot.io/ui/components/Accordion"; import { DebouncedTextInput } from "@typebot.io/ui/components/DebouncedTextInput"; import { Field } from "@typebot.io/ui/components/Field"; @@ -26,17 +28,29 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => { const updateAllowedFileTypes = (allowedFileTypes: string[]) => onOptionsChange({ ...options, + capture: isImageFileInput({ + ...options?.allowedFileTypes, + types: allowedFileTypes, + }) + ? options?.capture + : undefined, allowedFileTypes: { ...options?.allowedFileTypes, types: allowedFileTypes, }, }); - const updateAllowedFileTypesIsEnabled = (isEnabled: boolean) => + const updateAllowedFileTypesIsEnabled = (isEnabled: boolean) => { + const allowedFileTypes = { ...options?.allowedFileTypes, isEnabled }; + onOptionsChange({ ...options, - allowedFileTypes: { ...options?.allowedFileTypes, isEnabled }, + capture: isImageFileInput(allowedFileTypes) + ? options?.capture + : undefined, + allowedFileTypes, }); + }; const handleButtonLabelChange = (button: string) => onOptionsChange({ ...options, labels: { ...options?.labels, button } }); @@ -115,6 +129,26 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => { /> )} + {isImageFileInput(options?.allowedFileTypes) && ( + + + {t("blocks.inputs.file.settings.capture.label")} + + ({ + label: + captureMode === "environment" + ? t("blocks.inputs.file.settings.capture.environment.label") + : t("blocks.inputs.file.settings.capture.user.label"), + value: captureMode, + }))} + placeholder={t("blocks.inputs.file.settings.capture.none.label")} + value={options?.capture} + onChange={(capture) => onOptionsChange({ ...options, capture })} + /> + + )} { ) .not.toBe(200); }); + +test("applies the selected capture mode to image file inputs", async ({ + page, +}) => { + const typebotId = createId(); + const typebotWithoutCaptureId = createId(); + await createTypebots([ + { + id: typebotId, + publicId: `${typebotId}-public`, + groups: [ + { + id: "group1", + title: "Group #1", + graphCoordinates: { x: 0, y: 0 }, + blocks: [ + { + id: "capture-file-input", + type: InputBlockType.FILE, + options: { + allowedFileTypes: { + isEnabled: true, + types: [".jpg", ".png", ".jpeg", "capture=camera"], + }, + capture: "environment", + }, + }, + ], + }, + ], + }, + { + id: typebotWithoutCaptureId, + publicId: `${typebotWithoutCaptureId}-public`, + groups: [ + { + id: "group1", + title: "Group #1", + graphCoordinates: { x: 0, y: 0 }, + blocks: [ + { + id: "file-input-without-capture", + type: InputBlockType.FILE, + options: { + allowedFileTypes: { + isEnabled: true, + types: [".jpg", ".png", ".jpeg"], + }, + }, + }, + ], + }, + ], + }, + ]); + + await page.goto(`/${typebotId}-public`); + const fileInput = page.locator(`input[type="file"]`); + + await expect(fileInput).toHaveAttribute("capture", "environment"); + await expect(fileInput).toHaveAttribute( + "accept", + ".jpg, .png, .jpeg, image/jpeg, image/jpg, image/png", + ); + + await page.goto(`/${typebotWithoutCaptureId}-public`); + const fileInputWithoutCapture = page.locator(`input[type="file"]`); + + expect(await fileInputWithoutCapture.getAttribute("capture")).toBeNull(); + await expect(fileInputWithoutCapture).toHaveAttribute( + "accept", + ".jpg, .png, .jpeg, image/jpeg, image/jpg, image/png, capture=camera", + ); +}); diff --git a/commands/setup-worktree.ts b/commands/setup-worktree.ts index 63ea3f19d..d8cd42838 100644 --- a/commands/setup-worktree.ts +++ b/commands/setup-worktree.ts @@ -13,3 +13,4 @@ const mainWorktreeRoot = ( await $`cp ${mainWorktreeRoot}/.env ${currentWorktreeRoot}/.env`; await $`cp -R ${mainWorktreeRoot}/apps/viewer/src/test/.auth ${currentWorktreeRoot}/apps/viewer/src/test/.auth`; await $`cd ${currentWorktreeRoot} && bun install`; +await $`cd ${currentWorktreeRoot} && bunx playwright install chromium`; diff --git a/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.test.ts b/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.test.ts index 2e120345d..7f44ad7c8 100644 --- a/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.test.ts +++ b/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.test.ts @@ -64,6 +64,55 @@ describe("handleGenerateUploadUrl", () => { ); expect(response.maxFileSize).toBe(5); }); + + it("keeps enforcing an extension-based image allowlist for captured files", async () => { + publicTypebotFindFirstMock.mockResolvedValue( + buildPublicTypebot([".JPG", ".png", ".jpeg"]), + ); + + const response = await handleGenerateUploadUrl({ + input: { + sessionId, + blockId: blockIdFromSession, + fileName: "captured-image.jpg", + fileType: "image/jpeg", + }, + context: { apiOrigin: "http://localhost:3001" }, + }); + + expect( + parseSignedUploadFilePath(response.presignedUrl).endsWith(".jpeg"), + ).toBe(true); + await expect( + handleGenerateUploadUrl({ + input: { + sessionId, + blockId: blockIdFromSession, + fileName: "document.pdf", + fileType: "application/pdf", + }, + context: { apiOrigin: "http://localhost:3001" }, + }), + ).rejects.toThrow("File type application/pdf not allowed"); + }); + + it("fails closed when an enabled allowlist cannot be resolved", async () => { + publicTypebotFindFirstMock.mockResolvedValue( + buildPublicTypebot(["image/x-unknown"]), + ); + + await expect( + handleGenerateUploadUrl({ + input: { + sessionId, + blockId: blockIdFromSession, + fileName: "document.pdf", + fileType: "application/pdf", + }, + context: { apiOrigin: "http://localhost:3001" }, + }), + ).rejects.toThrow("File type application/pdf not allowed"); + }); }); const buildSession = () => ({ @@ -80,7 +129,7 @@ const buildSession = () => ({ }, }); -const buildPublicTypebot = () => ({ +const buildPublicTypebot = (allowedFileTypes?: string[]) => ({ version: "5", groups: [ { @@ -94,6 +143,10 @@ const buildPublicTypebot = () => ({ options: { visibility: "Private", sizeLimit: 5, + capture: allowedFileTypes ? "environment" : undefined, + allowedFileTypes: allowedFileTypes + ? { isEnabled: true, types: allowedFileTypes } + : undefined, }, }, ], diff --git a/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts b/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts index 0f240a871..04db12671 100644 --- a/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts +++ b/packages/blocks/fileInput/src/api/handleGenerateUploadUrl.ts @@ -98,7 +98,7 @@ export const handleGenerateUploadUrl = async ({ : undefined; if ( - allowedFileTypesMetadata?.length && + allowedFileTypesMetadata && (!fileType || !allowedFileTypesMetadata.some( (metadata) => metadata.mimeType === resolvedFileType, diff --git a/packages/blocks/inputs/package.json b/packages/blocks/inputs/package.json index 88359a277..4f71ff3af 100644 --- a/packages/blocks/inputs/package.json +++ b/packages/blocks/inputs/package.json @@ -11,6 +11,9 @@ }, "./package.json": "./package.json" }, + "scripts": { + "test": "bun test" + }, "dependencies": { "@typebot.io/blocks-base": "workspace:*", "@typebot.io/variables": "workspace:*", diff --git a/packages/blocks/inputs/src/file/constants.ts b/packages/blocks/inputs/src/file/constants.ts index a7ec0af10..cd845c55b 100644 --- a/packages/blocks/inputs/src/file/constants.ts +++ b/packages/blocks/inputs/src/file/constants.ts @@ -1,5 +1,12 @@ import type { FileInputBlock } from "./schema"; +export const fileCaptureModeOptions = [ + "environment", + "user", +] satisfies readonly ["environment", "user"]; + +export type FileCaptureMode = (typeof fileCaptureModeOptions)[number]; + export const defaultFileInputOptions = { isRequired: true, isMultipleAllowed: false, diff --git a/packages/blocks/inputs/src/file/schema.test.ts b/packages/blocks/inputs/src/file/schema.test.ts new file mode 100644 index 000000000..2517d378c --- /dev/null +++ b/packages/blocks/inputs/src/file/schema.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { InputBlockType } from "../constants"; +import { type FileInputBlockV6, fileInputBlockSchemas } from "./schema"; + +describe("fileInputBlockSchemas", () => { + it("parses existing blocks without adding a capture mode", () => { + const existingBlock = { + id: "file-input", + type: InputBlockType.FILE, + options: { + allowedFileTypes: { + isEnabled: true, + types: [".jpg", ".png", ".jpeg"], + }, + }, + } satisfies FileInputBlockV6; + + expect(fileInputBlockSchemas.v6.parse(existingBlock)).toEqual( + existingBlock, + ); + }); + + it.each([ + "environment", + "user", + ])("parses the standard %s capture mode", (capture) => { + expect( + fileInputBlockSchemas.v6.parse({ + id: "file-input", + type: InputBlockType.FILE, + options: { capture }, + }).options?.capture, + ).toBe(capture); + }); + + it("rejects non-standard capture modes", () => { + expect( + fileInputBlockSchemas.v6.safeParse({ + id: "file-input", + type: InputBlockType.FILE, + options: { capture: "camera" }, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/blocks/inputs/src/file/schema.ts b/packages/blocks/inputs/src/file/schema.ts index cfc42d877..4893222ad 100644 --- a/packages/blocks/inputs/src/file/schema.ts +++ b/packages/blocks/inputs/src/file/schema.ts @@ -4,7 +4,7 @@ import { } from "@typebot.io/blocks-base/schemas"; import { z } from "zod"; import { InputBlockType } from "../constants"; -import { fileVisibilityOptions } from "./constants"; +import { fileCaptureModeOptions, fileVisibilityOptions } from "./constants"; const fileInputOptionsV5Schema = optionBaseSchema.merge( z.object({ @@ -30,6 +30,7 @@ const fileInputOptionsV5Schema = optionBaseSchema.merge( types: z.array(z.string()).optional(), }) .optional(), + capture: z.enum(fileCaptureModeOptions).optional(), sizeLimit: z.number().optional(), visibility: z.enum(fileVisibilityOptions).optional(), }), diff --git a/packages/embeds/js/package.json b/packages/embeds/js/package.json index 4a2ded52d..65ffffd2f 100644 --- a/packages/embeds/js/package.json +++ b/packages/embeds/js/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/js", - "version": "0.10.6", + "version": "0.10.7", "description": "Javascript library to display typebots on your website", "license": "FSL-1.1-ALv2", "type": "module", diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx index 784d95c93..5d9e7e0c7 100644 --- a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx @@ -1,5 +1,6 @@ import { defaultFileInputOptions } from "@typebot.io/blocks-inputs/file/constants"; import type { FileInputBlock } from "@typebot.io/blocks-inputs/file/schema"; +import { isImageFileInput } from "@typebot.io/lib/isImageFileInput"; import { isDefined } from "@typebot.io/lib/utils"; import { defaultSystemMessages } from "@typebot.io/settings/constants"; import { @@ -190,6 +191,11 @@ export const FileUploadForm = (props: Props) => { ); }; + const capture = () => + isImageFileInput(props.block.options?.allowedFileTypes) + ? props.block.options?.capture + : undefined; + const placeholderMarkup = () => sanitizeHtmlFragment(props.block.options?.labels?.placeholder) ?? defaultFileInputOptions.labels.placeholder; @@ -255,9 +261,11 @@ export const FileUploadForm = (props: Props) => { props.block.options?.allowedFileTypes?.isEnabled ? injectAndroidCameraCaptureToMimeTypes( props.block.options.allowedFileTypes.types, + capture(), ) : undefined } + capture={capture()} multiple={ props.block.options?.isMultipleAllowed ?? defaultFileInputOptions.isMultipleAllowed diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.test.ts b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.test.ts index b1f47a00f..5e7ec7cb2 100644 --- a/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.test.ts +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.test.ts @@ -17,7 +17,22 @@ describe("injectAndroidCameraCaptureToMimeTypes", () => { expect( injectAndroidCameraCaptureToMimeTypes([".JPG", ".jpeg", "png"]), ).toBe( - ".JPG, .jpeg, png, image/jpg, image/jpeg, image/png, capture=camera", + ".JPG, .jpeg, png, image/jpeg, image/jpg, image/png, capture=camera", + ); + }); + + it("uses a clean image accept list with a standard capture attribute", () => { + expect( + injectAndroidCameraCaptureToMimeTypes( + [".jpg", ".png", ".jpeg", "capture=camera"], + "environment", + ), + ).toBe(".jpg, .png, .jpeg, image/jpeg, image/jpg, image/png"); + }); + + it("recognizes equivalent image extensions", () => { + expect(injectAndroidCameraCaptureToMimeTypes([".tiff"])).toBe( + ".tiff, image/tiff, capture=camera", ); }); diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.ts b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.ts index 41e6b93bb..be750c628 100644 --- a/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.ts +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/injectAndroidCameraCaptureToMimeTypes.ts @@ -1,11 +1,13 @@ -import { extensionFromMimeType } from "@typebot.io/lib/extensionFromMimeType"; +import type { FileCaptureMode } from "@typebot.io/blocks-inputs/file/constants"; +import { parseAllowedFileTypesMetadata } from "@typebot.io/lib/extensionFromMimeType"; /** - * Adds Android camera workaround to file types to ensure camera option appears on Android devices. - * This addresses an issue in Android 14+ where the camera option doesn't appear for image accept types. + * Adds Android camera workaround to file types when no standard capture attribute is selected. + * Matching image MIME types are always added so extension allowlists can activate media capture. */ export const injectAndroidCameraCaptureToMimeTypes = ( types?: string[], + capture?: FileCaptureMode, ): string => { if (!types || types.length === 0) return ""; @@ -16,7 +18,11 @@ export const injectAndroidCameraCaptureToMimeTypes = ( const existingFileTypeIndex = fileTypeIndexFromNormalizedFileType.get(normalizedFileType); - if (!normalizedFileType) return uniqueFileTypes; + if ( + !normalizedFileType || + (capture && normalizedFileType === "capture=camera") + ) + return uniqueFileTypes; if (existingFileTypeIndex === undefined) { fileTypeIndexFromNormalizedFileType.set( @@ -39,26 +45,27 @@ export const injectAndroidCameraCaptureToMimeTypes = ( fileTypeIndexFromNormalizedFileType.keys(), ); - const imageMimeTypesFromExtensions = uniqueFileTypes.flatMap((fileType) => - Object.entries(extensionFromMimeType).flatMap(([mimeType, extension]) => { - const normalizedMimeType = normalizeFileType(mimeType); + const imageMimeTypesFromExtensions = parseAllowedFileTypesMetadata( + uniqueFileTypes.filter( + (fileType) => !normalizeFileType(fileType).includes("/"), + ), + ).flatMap(({ mimeType }) => { + const normalizedMimeType = normalizeFileType(mimeType); - if ( - !normalizedMimeType.startsWith("image/") || - normalizeFileType(fileType) !== normalizeFileType(extension) || - normalizedFileTypes.has(normalizedMimeType) - ) - return []; + if ( + !normalizedMimeType.startsWith("image/") || + normalizedFileTypes.has(normalizedMimeType) + ) + return []; - normalizedFileTypes.add(normalizedMimeType); - return [mimeType]; - }), - ); + normalizedFileTypes.add(normalizedMimeType); + return [mimeType]; + }); if (![...normalizedFileTypes].some((type) => type.startsWith("image/"))) return uniqueFileTypes.join(", "); - if (normalizedFileTypes.has("capture=camera")) + if (capture || normalizedFileTypes.has("capture=camera")) return [...uniqueFileTypes, ...imageMimeTypesFromExtensions].join(", "); return [ diff --git a/packages/embeds/react/package.json b/packages/embeds/react/package.json index 116c8ac22..d5fcc19f2 100644 --- a/packages/embeds/react/package.json +++ b/packages/embeds/react/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/react", - "version": "0.10.6", + "version": "0.10.7", "description": "Convenient library to display typebots on your React app", "license": "FSL-1.1-ALv2", "type": "module", diff --git a/packages/lib/src/extensionFromMimeType.test.ts b/packages/lib/src/extensionFromMimeType.test.ts new file mode 100644 index 000000000..4b4e5829c --- /dev/null +++ b/packages/lib/src/extensionFromMimeType.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { parseAllowedFileTypesMetadata } from "./extensionFromMimeType"; + +describe("parseAllowedFileTypesMetadata", () => { + it("normalizes MIME types and extension aliases", () => { + expect(parseAllowedFileTypesMetadata(["IMAGE/PNG"])).toEqual([ + { mimeType: "image/png", extension: "png" }, + ]); + expect(parseAllowedFileTypesMetadata([".jpg"])).toEqual([ + { mimeType: "image/jpeg", extension: "jpeg" }, + { mimeType: "image/jpg", extension: "jpg" }, + ]); + expect(parseAllowedFileTypesMetadata([".tiff"])).toEqual([ + { mimeType: "image/tiff", extension: "tif" }, + ]); + }); + + it("matches extensions exactly", () => { + expect(parseAllowedFileTypesMetadata([".docx"])).toEqual([ + { + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + extension: "docx", + }, + ]); + }); + + it("does not resolve unknown types or legacy capture tokens", () => { + expect(parseAllowedFileTypesMetadata(["image/x-unknown"])).toEqual([]); + expect(parseAllowedFileTypesMetadata(["capture=camera"])).toEqual([]); + }); +}); diff --git a/packages/lib/src/extensionFromMimeType.ts b/packages/lib/src/extensionFromMimeType.ts index 636edf8c3..ff3fbc477 100644 --- a/packages/lib/src/extensionFromMimeType.ts +++ b/packages/lib/src/extensionFromMimeType.ts @@ -58,17 +58,36 @@ export const parseAllowedFileTypesMetadata = ( mimeType: string; extension: string; }[] => { - const wildcardExtensions = allowedFileTypes - .filter((ext) => ext.includes("*")) - .map((ext) => ext.split("/")[0]); + const normalizedAllowedFileTypes = allowedFileTypes.map((fileType) => + fileType.trim().toLowerCase(), + ); + const wildcardMimeTypes = new Set( + normalizedAllowedFileTypes + .filter((fileType) => fileType.endsWith("/*")) + .map((fileType) => fileType.slice(0, -2)), + ); + const allowedExtensions = new Set( + normalizedAllowedFileTypes + .filter((fileType) => !fileType.includes("/")) + .map((fileType) => + normalizeEquivalentExtension(fileType.replace(/^\./, "")), + ), + ); return Object.entries(extensionFromMimeType) .filter(([mimeType, extension]) => { const mimeBaseType = mimeType.split("/")[0]; return ( - allowedFileTypes.some((fileType) => fileType.includes(extension)) || - wildcardExtensions.includes(mimeBaseType) + normalizedAllowedFileTypes.includes(mimeType) || + allowedExtensions.has(normalizeEquivalentExtension(extension)) || + (mimeBaseType !== undefined && wildcardMimeTypes.has(mimeBaseType)) ); }) .map(([mimeType, extension]) => ({ mimeType, extension })); }; + +const normalizeEquivalentExtension = (extension: string) => { + if (extension === "jpg" || extension === "jpe") return "jpeg"; + if (extension === "tiff") return "tif"; + return extension; +}; diff --git a/packages/lib/src/isImageFileInput.test.ts b/packages/lib/src/isImageFileInput.test.ts new file mode 100644 index 000000000..3809b1eb2 --- /dev/null +++ b/packages/lib/src/isImageFileInput.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { isImageFileInput } from "./isImageFileInput"; + +describe("isImageFileInput", () => { + it("recognizes enabled image extension and MIME type allowlists", () => { + expect( + isImageFileInput({ + isEnabled: true, + types: [".JPG", "png", ".jpeg", "capture=camera"], + }), + ).toBe(true); + expect( + isImageFileInput({ + isEnabled: true, + types: ["IMAGE/PNG", "image/*"], + }), + ).toBe(true); + expect(isImageFileInput({ isEnabled: true, types: [".tiff"] })).toBe(true); + }); + + it("rejects mixed or non-image file type allowlists", () => { + expect(isImageFileInput({ isEnabled: true, types: [".jpg", ".pdf"] })).toBe( + false, + ); + expect( + isImageFileInput({ isEnabled: true, types: ["application/pdf"] }), + ).toBe(false); + expect( + isImageFileInput({ isEnabled: true, types: ["image/x-unknown"] }), + ).toBe(false); + expect( + isImageFileInput({ isEnabled: true, types: ["capture=camera"] }), + ).toBe(false); + }); + + it("rejects disabled, missing, and empty allowlists", () => { + expect(isImageFileInput()).toBe(false); + expect(isImageFileInput({ isEnabled: false, types: [".jpg"] })).toBe(false); + expect(isImageFileInput({ isEnabled: true })).toBe(false); + expect(isImageFileInput({ isEnabled: true, types: [] })).toBe(false); + expect(isImageFileInput({ isEnabled: true, types: [" "] })).toBe(false); + }); +}); diff --git a/packages/lib/src/isImageFileInput.ts b/packages/lib/src/isImageFileInput.ts new file mode 100644 index 000000000..89cabdc6c --- /dev/null +++ b/packages/lib/src/isImageFileInput.ts @@ -0,0 +1,27 @@ +import { parseAllowedFileTypesMetadata } from "./extensionFromMimeType"; + +export const isImageFileInput = (allowedFileTypes?: { + isEnabled?: boolean; + types?: string[]; +}) => { + if ( + allowedFileTypes?.isEnabled !== true || + allowedFileTypes.types === undefined + ) + return false; + + const effectiveAllowedFileTypes = allowedFileTypes.types.filter( + (fileType) => fileType.trim().toLowerCase() !== "capture=camera", + ); + + return ( + effectiveAllowedFileTypes.length > 0 && + effectiveAllowedFileTypes.every((fileType) => { + const fileTypesMetadata = parseAllowedFileTypesMetadata([fileType]); + return ( + fileTypesMetadata.length > 0 && + fileTypesMetadata.every(({ mimeType }) => mimeType.startsWith("image/")) + ); + }) + ); +};