From 0bf6801650a6c705b5648f934a42b7d325669bfe Mon Sep 17 00:00:00 2001 From: Sruhvx-jpg Date: Wed, 19 Aug 2026 15:24:35 +0530 Subject: [PATCH] perf(web): optimize document upload validation with static sets, MIME checks, and 50MB size guard - Hoist extension and MIME type Set allocations to module scope in document-file-validation - Add fail-fast 50MB size check to reject oversized uploads prior to network requests - Support direct MIME lookup for PDFs, images, and standard office documents alongside extension fallback - Add unit test suite covering extension, MIME, size, and corrupt/empty file validation Fixes #1559 --- apps/web/components/add-document/file.tsx | 68 +++++++++++-------- apps/web/lib/document-file-validation.test.ts | 68 +++++++++++++++++++ apps/web/lib/document-file-validation.ts | 47 +++++++++++++ 3 files changed, 153 insertions(+), 30 deletions(-) create mode 100644 apps/web/lib/document-file-validation.test.ts create mode 100644 apps/web/lib/document-file-validation.ts diff --git a/apps/web/components/add-document/file.tsx b/apps/web/components/add-document/file.tsx index aa9388e70..575a7fc5f 100644 --- a/apps/web/components/add-document/file.tsx +++ b/apps/web/components/add-document/file.tsx @@ -6,6 +6,10 @@ import { dmSansClassName } from "@/lib/fonts" import { FileIcon, XIcon, AlertCircleIcon, CheckIcon } from "lucide-react" import { useHotkeys } from "react-hotkeys-hook" import { toast } from "sonner" +import { + isAcceptedFileType, + MAX_DOCUMENT_FILE_BYTES, +} from "@/lib/document-file-validation" export const FILE_ACCEPT = "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,.json,.html,.htm,text/markdown,application/json,text/html" @@ -33,31 +37,6 @@ interface FileContentProps { isOpen?: boolean } -function isAcceptedFile(file: File): boolean { - const name = file.name.toLowerCase() - const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : "" - const allowedExt = new Set([ - ".pdf", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".csv", - ".txt", - ".md", - ".mdx", - ".json", - ".html", - ".htm", - ]) - if (allowedExt.has(ext)) return true - if (file.type.startsWith("image/")) return true - if (file.type === "text/markdown") return true - if (file.type === "application/json") return true - if (file.type === "text/html") return true - return false -} - function fileQueueKey(file: File): string { return `${file.name}:${file.size}:${file.lastModified}` } @@ -100,13 +79,42 @@ export function FileContent({ const addFiles = useCallback( (fileList: FileList | File[]) => { const incoming = Array.from(fileList) - const accepted = incoming.filter(isAcceptedFile) - const rejected = incoming.length - accepted.length - if (rejected > 0) { + const accepted: File[] = [] + let emptyCount = 0 + let oversizedCount = 0 + let unsupportedCount = 0 + + for (const file of incoming) { + if (file.size <= 0) { + emptyCount++ + } else if (file.size > MAX_DOCUMENT_FILE_BYTES) { + oversizedCount++ + } else if (!isAcceptedFileType(file)) { + unsupportedCount++ + } else { + accepted.push(file) + } + } + + if (emptyCount > 0) { + toast.error( + emptyCount === 1 + ? "One file is empty" + : `${emptyCount} files are empty`, + ) + } + if (oversizedCount > 0) { + toast.error( + oversizedCount === 1 + ? "One file exceeds the 50MB limit" + : `${oversizedCount} files exceed the 50MB limit`, + ) + } + if (unsupportedCount > 0) { toast.error( - rejected === 1 + unsupportedCount === 1 ? "One file type is not supported" - : `${rejected} files are not supported`, + : `${unsupportedCount} files are not supported`, ) } if (accepted.length === 0) return diff --git a/apps/web/lib/document-file-validation.test.ts b/apps/web/lib/document-file-validation.test.ts new file mode 100644 index 000000000..52eaed92b --- /dev/null +++ b/apps/web/lib/document-file-validation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test" +import { + isAcceptedFile, + isAcceptedFileType, + MAX_DOCUMENT_FILE_BYTES, +} from "./document-file-validation" + +function createMockFile(name: string, size = 1024, type = ""): File { + return new File([new Uint8Array(size)], name, { type }) +} + +describe("document file validation", () => { + it("accepts standard documents by extension", () => { + expect(isAcceptedFile(createMockFile("document.pdf"))).toBe(true) + expect(isAcceptedFile(createMockFile("notes.md"))).toBe(true) + expect(isAcceptedFile(createMockFile("data.json"))).toBe(true) + expect(isAcceptedFile(createMockFile("sheet.xlsx"))).toBe(true) + expect(isAcceptedFile(createMockFile("report.docx"))).toBe(true) + expect(isAcceptedFile(createMockFile("data.csv"))).toBe(true) + }) + + it("accepts files with uppercase extensions and multi-dot filenames", () => { + expect(isAcceptedFile(createMockFile("DOCUMENT.PDF"))).toBe(true) + expect(isAcceptedFile(createMockFile("archive.v1.0.final.docx"))).toBe(true) + expect(isAcceptedFile(createMockFile("report.2026.08.19.csv"))).toBe(true) + }) + + it("accepts extensionless or generic files matching valid MIME types", () => { + expect( + isAcceptedFile(createMockFile("blob", 1024, "application/pdf")), + ).toBe(true) + expect( + isAcceptedFile(createMockFile("uploaded-file", 1024, "application/json")), + ).toBe(true) + expect( + isAcceptedFile(createMockFile("image-upload", 1024, "image/png")), + ).toBe(true) + expect(isAcceptedFile(createMockFile("photo", 1024, "image/jpeg"))).toBe( + true, + ) + }) + + it("correctly evaluates isAcceptedFileType independent of file size", () => { + expect(isAcceptedFileType(createMockFile("empty.pdf", 0))).toBe(true) + expect( + isAcceptedFileType( + createMockFile("large.pdf", MAX_DOCUMENT_FILE_BYTES + 1), + ), + ).toBe(true) + expect(isAcceptedFileType(createMockFile("script.sh"))).toBe(false) + }) + + it("rejects files exceeding the 50MB limit in isAcceptedFile", () => { + const oversized = MAX_DOCUMENT_FILE_BYTES + 1 + expect(isAcceptedFile(createMockFile("large.pdf", oversized))).toBe(false) + }) + + it("rejects empty files with 0 bytes in isAcceptedFile", () => { + expect(isAcceptedFile(createMockFile("empty.pdf", 0))).toBe(false) + }) + + it("rejects unsupported extensions and executables", () => { + expect(isAcceptedFile(createMockFile("malware.exe"))).toBe(false) + expect(isAcceptedFile(createMockFile("script.sh"))).toBe(false) + expect(isAcceptedFile(createMockFile("archive.zip"))).toBe(false) + expect(isAcceptedFile(createMockFile("binary.bin"))).toBe(false) + }) +}) diff --git a/apps/web/lib/document-file-validation.ts b/apps/web/lib/document-file-validation.ts new file mode 100644 index 000000000..4762a8d7f --- /dev/null +++ b/apps/web/lib/document-file-validation.ts @@ -0,0 +1,47 @@ +export const MAX_DOCUMENT_FILE_BYTES = 50 * 1024 * 1024 // 50MB + +export const ALLOWED_EXTENSIONS = new Set([ + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".csv", + ".txt", + ".md", + ".mdx", + ".json", + ".html", + ".htm", +]) + +export const ALLOWED_MIME_TYPES = new Set([ + "application/pdf", + "application/json", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "text/markdown", + "text/html", + "text/plain", + "text/csv", +]) + +export function isAcceptedFileType(file: File): boolean { + if (file.type) { + const baseMime = file.type.split(";")[0]?.trim().toLowerCase() ?? "" + if (baseMime.startsWith("image/")) return true + if (ALLOWED_MIME_TYPES.has(baseMime)) return true + } + + const extIndex = file.name.lastIndexOf(".") + if (extIndex === -1) return false + + return ALLOWED_EXTENSIONS.has(file.name.slice(extIndex).toLowerCase()) +} + +export function isAcceptedFile(file: File): boolean { + if (file.size <= 0 || file.size > MAX_DOCUMENT_FILE_BYTES) return false + return isAcceptedFileType(file) +}