mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-07-21 21:13:45 +08:00
👌 Add camera capture options to file uploads (#2560)
## 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:
parent
76df665e14
commit
512c988951
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -98,7 +98,7 @@ export const handleGenerateUploadUrl = async ({
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
allowedFileTypesMetadata?.length &&
|
||||
allowedFileTypesMetadata &&
|
||||
(!fileType ||
|
||||
!allowedFileTypesMetadata.some(
|
||||
(metadata) => metadata.mimeType === resolvedFileType,
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typebot.io/blocks-base": "workspace:*",
|
||||
"@typebot.io/variables": "workspace:*",
|
||||
|
||||
@ -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,
|
||||
|
||||
45
packages/blocks/inputs/src/file/schema.test.ts
Normal file
45
packages/blocks/inputs/src/file/schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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(),
|
||||
}),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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",
|
||||
|
||||
32
packages/lib/src/extensionFromMimeType.test.ts
Normal file
32
packages/lib/src/extensionFromMimeType.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
|
||||
43
packages/lib/src/isImageFileInput.test.ts
Normal file
43
packages/lib/src/isImageFileInput.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
packages/lib/src/isImageFileInput.ts
Normal file
27
packages/lib/src/isImageFileInput.ts
Normal 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/"))
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user