From 144c9b2464427d2f388815eff792e864f105d57a Mon Sep 17 00:00:00 2001 From: luobo <44131846+who96@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:42:26 +0800 Subject: [PATCH] fix(frontend): block unsupported .app uploads (#1834) Co-authored-by: Willem Jiang --- .../components/ai-elements/prompt-input.tsx | 56 ++++++++++++++++--- .../src/core/uploads/file-validation.test.mjs | 55 ++++++++++++++++++ frontend/src/core/uploads/file-validation.ts | 34 +++++++++++ frontend/src/core/uploads/index.ts | 1 + 4 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 frontend/src/core/uploads/file-validation.test.mjs create mode 100644 frontend/src/core/uploads/file-validation.ts diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index c178a5b8f..015dd56fd 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -34,6 +34,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { splitUnsupportedUploadFiles } from "@/core/uploads"; import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; import type { ChatStatus, FileUIPart } from "ai"; @@ -71,6 +72,7 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; // ============================================================================ // Provider Context & Types @@ -107,6 +109,9 @@ const PromptInputController = createContext( const ProviderAttachmentsContext = createContext( null, ); +const PromptInputValidationContext = createContext< + ((files: File[] | FileList) => File[]) | null +>(null); export const usePromptInputController = () => { const ctx = useContext(PromptInputController); @@ -134,6 +139,7 @@ export const useProviderAttachments = () => { const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); +const usePromptInputValidation = () => useContext(PromptInputValidationContext); export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; @@ -451,7 +457,7 @@ export type PromptInputProps = Omit< maxFiles?: number; maxFileSize?: number; // bytes onError?: (err: { - code: "max_files" | "max_file_size" | "accept"; + code: "max_files" | "max_file_size" | "accept" | "unsupported_package"; message: string; }) => void; onSubmit: ( @@ -599,6 +605,23 @@ export const PromptInput = ({ ? controller.attachments.openFileDialog : openFileDialogLocal; + const sanitizeIncomingFiles = useCallback( + (fileList: File[] | FileList) => { + const { accepted, message } = splitUnsupportedUploadFiles(fileList); + if (message) { + onError?.({ + code: "unsupported_package", + message, + }); + if (!onError) { + toast.error(message); + } + } + return accepted; + }, + [onError], + ); + // Let provider know about our hidden file input so external menus can call openFileDialog() useEffect(() => { if (!usingProvider) return; @@ -629,7 +652,10 @@ export const PromptInput = ({ e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); + const accepted = sanitizeIncomingFiles(e.dataTransfer.files); + if (accepted.length > 0) { + add(accepted); + } } }; form.addEventListener("dragover", onDragOver); @@ -638,7 +664,7 @@ export const PromptInput = ({ form.removeEventListener("dragover", onDragOver); form.removeEventListener("drop", onDrop); }; - }, [add, globalDrop]); + }, [add, globalDrop, sanitizeIncomingFiles]); useEffect(() => { if (!globalDrop) return; @@ -653,7 +679,10 @@ export const PromptInput = ({ e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); + const accepted = sanitizeIncomingFiles(e.dataTransfer.files); + if (accepted.length > 0) { + add(accepted); + } } }; document.addEventListener("dragover", onDragOver); @@ -662,7 +691,7 @@ export const PromptInput = ({ document.removeEventListener("dragover", onDragOver); document.removeEventListener("drop", onDrop); }; - }, [add, globalDrop]); + }, [add, globalDrop, sanitizeIncomingFiles]); useEffect( () => () => { @@ -678,7 +707,10 @@ export const PromptInput = ({ const handleChange: ChangeEventHandler = (event) => { if (event.currentTarget.files) { - add(event.currentTarget.files); + const accepted = sanitizeIncomingFiles(event.currentTarget.files); + if (accepted.length > 0) { + add(accepted); + } } // Reset input value to allow selecting files that were previously removed event.currentTarget.value = ""; @@ -778,7 +810,7 @@ export const PromptInput = ({ // Render with or without local provider const inner = ( - <> + {children} - + ); return usingProvider ? ( @@ -830,6 +862,7 @@ export const PromptInputTextarea = ({ }: PromptInputTextareaProps) => { const controller = useOptionalPromptInputController(); const attachments = usePromptInputAttachments(); + const sanitizeIncomingFiles = usePromptInputValidation(); const [isComposing, setIsComposing] = useState(false); const handleKeyDown: KeyboardEventHandler = (e) => { @@ -888,7 +921,12 @@ export const PromptInputTextarea = ({ if (files.length > 0) { event.preventDefault(); - attachments.add(files); + const accepted = sanitizeIncomingFiles + ? sanitizeIncomingFiles(files) + : files; + if (accepted.length > 0) { + attachments.add(accepted); + } } }; diff --git a/frontend/src/core/uploads/file-validation.test.mjs b/frontend/src/core/uploads/file-validation.test.mjs new file mode 100644 index 000000000..62a903e97 --- /dev/null +++ b/frontend/src/core/uploads/file-validation.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MACOS_APP_BUNDLE_UPLOAD_MESSAGE, + isLikelyMacOSAppBundle, + splitUnsupportedUploadFiles, +} from "./file-validation.ts"; + +test("identifies Finder-style .app bundle uploads as unsupported", () => { + assert.equal( + isLikelyMacOSAppBundle({ + name: "Vibe Island.app", + type: "application/octet-stream", + }), + true, + ); +}); + +test("keeps normal files and reports rejected app bundles", () => { + const files = [ + new File(["demo"], "Vibe Island.app", { + type: "application/octet-stream", + }), + new File(["notes"], "notes.txt", { type: "text/plain" }), + ]; + + const result = splitUnsupportedUploadFiles(files); + + assert.equal(result.accepted.length, 1); + assert.equal(result.accepted[0]?.name, "notes.txt"); + assert.equal(result.rejected.length, 1); + assert.equal(result.rejected[0]?.name, "Vibe Island.app"); + assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE); +}); + +test("treats empty MIME .app uploads as unsupported", () => { + const result = splitUnsupportedUploadFiles([ + new File(["demo"], "Another.app", { type: "" }), + ]); + + assert.equal(result.accepted.length, 0); + assert.equal(result.rejected.length, 1); + assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE); +}); + +test("returns no message when every file is supported", () => { + const result = splitUnsupportedUploadFiles([ + new File(["notes"], "notes.txt", { type: "text/plain" }), + ]); + + assert.equal(result.accepted.length, 1); + assert.equal(result.rejected.length, 0); + assert.equal(result.message, undefined); +}); diff --git a/frontend/src/core/uploads/file-validation.ts b/frontend/src/core/uploads/file-validation.ts new file mode 100644 index 000000000..1b796a9ac --- /dev/null +++ b/frontend/src/core/uploads/file-validation.ts @@ -0,0 +1,34 @@ +const MACOS_APP_BUNDLE_CONTENT_TYPES = new Set([ + "", + "application/octet-stream", +]); + +export const MACOS_APP_BUNDLE_UPLOAD_MESSAGE = + "macOS .app bundles can't be uploaded directly from the browser. Compress the app as a .zip or upload the .dmg instead."; + +export function isLikelyMacOSAppBundle(file: Pick) { + return ( + file.name.toLowerCase().endsWith(".app") && + MACOS_APP_BUNDLE_CONTENT_TYPES.has(file.type) + ); +} + +export function splitUnsupportedUploadFiles(fileList: File[] | FileList) { + const incoming = Array.from(fileList); + const accepted: File[] = []; + const rejected: File[] = []; + + for (const file of incoming) { + if (isLikelyMacOSAppBundle(file)) { + rejected.push(file); + continue; + } + accepted.push(file); + } + + return { + accepted, + rejected, + message: rejected.length > 0 ? MACOS_APP_BUNDLE_UPLOAD_MESSAGE : undefined, + }; +} diff --git a/frontend/src/core/uploads/index.ts b/frontend/src/core/uploads/index.ts index 4c11a990e..5f66e7dfe 100644 --- a/frontend/src/core/uploads/index.ts +++ b/frontend/src/core/uploads/index.ts @@ -3,4 +3,5 @@ */ export * from "./api"; +export * from "./file-validation"; export * from "./hooks";