mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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
This commit is contained in:
parent
18a2dfbe39
commit
0bf6801650
3 changed files with 153 additions and 30 deletions
|
|
@ -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(
|
||||
rejected === 1
|
||||
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(
|
||||
unsupportedCount === 1
|
||||
? "One file type is not supported"
|
||||
: `${rejected} files are not supported`,
|
||||
: `${unsupportedCount} files are not supported`,
|
||||
)
|
||||
}
|
||||
if (accepted.length === 0) return
|
||||
|
|
|
|||
68
apps/web/lib/document-file-validation.test.ts
Normal file
68
apps/web/lib/document-file-validation.test.ts
Normal file
|
|
@ -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
apps/web/lib/document-file-validation.ts
Normal file
47
apps/web/lib/document-file-validation.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue