Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 38 additions & 30 deletions apps/web/components/add-document/file.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}`
}
Expand Down Expand Up @@ -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)
}
}
Comment on lines +87 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty files (size = 0) are incorrectly categorized as "unsupported" rather than being handled separately. An empty PDF file will show "One file type is not supported" even though the file type is valid.

Impact: Misleading user feedback - users will think they uploaded the wrong file type when they actually uploaded an empty file.

Fix: Add a separate check for empty files:

for (const file of incoming) {
  if (file.size <= 0) {
    emptyCount++
  } else if (file.size > MAX_DOCUMENT_FILE_BYTES) {
    oversizedCount++
  } else if (!isAcceptedFile(file)) {
    unsupportedCount++
  } else {
    accepted.push(file)
  }
}

Then add appropriate toast message for empty files.

Suggested change
for (const file of incoming) {
if (file.size > MAX_DOCUMENT_FILE_BYTES) {
oversizedCount++
} else if (!isAcceptedFile(file)) {
unsupportedCount++
} else {
accepted.push(file)
}
}
for (const file of incoming) {
if (file.size <= 0) {
emptyCount++
} else if (file.size > MAX_DOCUMENT_FILE_BYTES) {
oversizedCount++
} else if (!isAcceptedFile(file)) {
unsupportedCount++
} else {
accepted.push(file)
}
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


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
Expand Down
68 changes: 68 additions & 0 deletions apps/web/lib/document-file-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
47 changes: 47 additions & 0 deletions apps/web/lib/document-file-validation.ts
Original file line number Diff line number Diff line change
@@ -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)
}