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/"))
+ );
+ })
+ );
+};