👌 Add camera capture options to file uploads (#2560)
Some checks failed
Create Tag / create-tag (push) Has been cancelled
Deploy Workflows (Fly.io) / deploy (push) Has been cancelled

## What changed

- add an optional Capture setting for image-only File Upload blocks with
None, Back camera, and Front camera choices
- persist the standard `environment` and `user` values and apply them
through the runtime file input `capture` attribute
- preserve existing behavior when capture is unset, including the legacy
Android accept workaround
- normalize extension-based image allowlists for capture while keeping
server-side validation fail-closed
- update schemas, generated OpenAPI specs, embed package versions, and
focused tests
- install Playwright Chromium during Codex worktree setup and ignore
local Revue session state

## Validation

- pre-commit affected format, lint, link, test, and OpenAPI targets:
passed
- affected tests across 14 projects under `.env.dev.example`: passed
- typecheck for builder, viewer, JS embed, File Input, input schemas,
lib, and bot engine: passed
- targeted Viewer Playwright file-upload capture test: passed (2 tests)
- targeted schema, allowlist, runtime helper, and upload validation
suites: passed

## Browser behavior

The standard capture attribute is a browser hint. Supported mobile
browsers may open the selected camera directly; desktop and unsupported
browsers may continue to show a regular file chooser.
This commit is contained in:
Baptiste Arnaud 2026-07-15 18:53:17 +02:00 committed by GitHub
parent 76df665e14
commit 512c988951
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 533 additions and 32 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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) => {
/>
)}
</Field.Container>
{isImageFileInput(options?.allowedFileTypes) && (
<Field.Root>
<Field.Label>
{t("blocks.inputs.file.settings.capture.label")}
</Field.Label>
<BasicSelect
className="w-full"
items={fileCaptureModeOptions.map((captureMode) => ({
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 })}
/>
</Field.Root>
)}
<Field.Root className="flex-row items-center">
<Switch
checked={

View File

@ -165,6 +165,10 @@
"blocks.inputs.file.settings.allowMultiple.label": "Allow multiple files",
"blocks.inputs.file.settings.allowedFileTypes.label": "Restrict file type",
"blocks.inputs.file.settings.allowedFileTypes.placeholder": ".jpg, .png, .pdf, image/*",
"blocks.inputs.file.settings.capture.environment.label": "Back camera",
"blocks.inputs.file.settings.capture.label": "Capture",
"blocks.inputs.file.settings.capture.none.label": "None",
"blocks.inputs.file.settings.capture.user.label": "Front camera",
"blocks.inputs.file.settings.clear.label": "Clear button label:",
"blocks.inputs.file.settings.required.label": "Required",
"blocks.inputs.file.settings.saveMultipleUpload.label": "Save upload URLs in a variable:",

View File

@ -4612,6 +4612,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"visibility": {
"enum": [
"Auto",
@ -12896,6 +12903,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -15156,6 +15170,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -17416,6 +17437,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -19695,6 +19723,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -23412,6 +23447,7 @@
"isMultipleAllowed": {},
"labels": {},
"allowedFileTypes": {},
"capture": {},
"sizeLimit": {},
"visibility": {}
}
@ -37073,6 +37109,13 @@
"types": {}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -40661,6 +40704,13 @@
"types": {}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -49094,6 +49144,13 @@
"types": {}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -52491,6 +52548,13 @@
"types": {}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -75516,6 +75580,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -78216,6 +78287,7 @@
"isMultipleAllowed": {},
"labels": {},
"allowedFileTypes": {},
"capture": {},
"sizeLimit": {},
"visibility": {}
}
@ -80772,6 +80844,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},

View File

@ -1847,6 +1847,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"visibility": {
"enum": [
"Auto",
@ -2320,6 +2327,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -4719,6 +4733,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"visibility": {
"enum": [
"Auto",
@ -5192,6 +5213,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},
@ -7463,6 +7491,7 @@
"isMultipleAllowed": {},
"labels": {},
"allowedFileTypes": {},
"capture": {},
"visibility": {}
}
}
@ -10197,6 +10226,7 @@
"isMultipleAllowed": {},
"labels": {},
"allowedFileTypes": {},
"capture": {},
"sizeLimit": {},
"visibility": {}
}
@ -13398,6 +13428,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"visibility": {
"enum": [
"Auto",
@ -13871,6 +13908,13 @@
}
}
},
"capture": {
"enum": [
"environment",
"user"
],
"type": "string"
},
"sizeLimit": {
"type": "number"
},

View File

@ -1,10 +1,14 @@
import { readFileSync } from "node:fs";
import { createId } from "@paralleldrive/cuid2";
import test, { expect } from "@playwright/test";
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
import { env } from "@typebot.io/env";
import { parseS3PublicBaseUrl } from "@typebot.io/lib/s3/parseS3PublicBaseUrl";
import { isDefined } from "@typebot.io/lib/utils";
import { importTypebotInDatabase } from "@typebot.io/playwright/databaseActions";
import {
createTypebots,
importTypebotInDatabase,
} from "@typebot.io/playwright/databaseActions";
import { parse } from "papaparse";
import { getTestAsset } from "@/test/utils/playwright";
@ -81,3 +85,77 @@ test("should work as expected", async ({ page, browser }) => {
)
.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",
);
});

View File

@ -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`;

View File

@ -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,
},
},
],

View File

@ -98,7 +98,7 @@ export const handleGenerateUploadUrl = async ({
: undefined;
if (
allowedFileTypesMetadata?.length &&
allowedFileTypesMetadata &&
(!fileType ||
!allowedFileTypesMetadata.some(
(metadata) => metadata.mimeType === resolvedFileType,

View File

@ -11,6 +11,9 @@
},
"./package.json": "./package.json"
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@typebot.io/blocks-base": "workspace:*",
"@typebot.io/variables": "workspace:*",

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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(),
}),

View File

@ -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",

View File

@ -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

View File

@ -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",
);
});

View File

@ -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 [

View File

@ -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",

View File

@ -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([]);
});
});

View File

@ -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;
};

View File

@ -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);
});
});

View File

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