fix(uploads): resolve .md file upload rejection and deduplicate file type utilities

Browsers report empty or application/octet-stream MIME types for .md files,
causing copilot uploads to be rejected. Added resolveFileType() utility that
falls back to extension-based MIME resolution at both client and server
boundaries. Consolidated duplicate MIME mappings into module-level constants,
removed duplicate isImageFileType from copilot module, and replaced hardcoded
ALLOWED_EXTENSIONS with composition from shared validation constants. Also
switched file attachment previews to use shared getDocumentIcon utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
waleed
2026-03-11 04:44:50 -07:00
parent 079c7caec3
commit 95557bda79
8 changed files with 174 additions and 169 deletions

View File

@@ -78,10 +78,13 @@ vi.mock('@/lib/uploads/utils/validation', () => ({
validateFileType: mockValidateFileType,
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
isImageFileType: mockIsImageFileType,
}))
vi.mock('@/lib/uploads', () => ({
CopilotFiles: {
generateCopilotUploadUrl: mockGenerateCopilotUploadUrl,
isImageFileType: mockIsImageFileType,
},
getStorageProvider: mockGetStorageProviderUploads,
isUsingCloudStorage: mockIsUsingCloudStorageUploads,

View File

@@ -5,6 +5,7 @@ import { CopilotFiles } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import { validateFileType } from '@/lib/uploads/utils/validation'
import { createErrorResponse } from '@/app/api/files/utils'
@@ -132,7 +133,7 @@ export async function POST(request: NextRequest) {
'Authenticated user session is required for profile picture uploads'
)
}
if (!CopilotFiles.isImageFileType(contentType)) {
if (!isImageFileType(contentType)) {
throw new ValidationError(
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads'
)

View File

@@ -4,8 +4,13 @@ import { sanitizeFileName } from '@/executor/constants'
import '@/lib/uploads/core/setup.server'
import { getSession } from '@/lib/auth'
import type { StorageContext } from '@/lib/uploads/config'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import { validateFileType } from '@/lib/uploads/utils/validation'
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
validateFileType,
} from '@/lib/uploads/utils/validation'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
createErrorResponse,
@@ -13,38 +18,13 @@ import {
InvalidRequestError,
} from '@/app/api/files/utils'
const ALLOWED_EXTENSIONS = new Set([
// Documents
'pdf',
'doc',
'docx',
'txt',
'md',
'csv',
'xlsx',
'xls',
'json',
'yaml',
'yml',
// Images
'png',
'jpg',
'jpeg',
'gif',
// Audio
'mp3',
'm4a',
'wav',
'webm',
'ogg',
'flac',
'aac',
'opus',
// Video
'mp4',
'mov',
'avi',
'mkv',
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] as const
const ALLOWED_EXTENSIONS = new Set<string>([
...SUPPORTED_DOCUMENT_EXTENSIONS,
...IMAGE_EXTENSIONS,
...SUPPORTED_AUDIO_EXTENSIONS,
...SUPPORTED_VIDEO_EXTENSIONS,
])
function validateFileExtension(filename: string): boolean {
@@ -257,7 +237,8 @@ export async function POST(request: NextRequest) {
const { isSupportedFileType: isCopilotSupported } = await import(
'@/lib/uploads/contexts/copilot/copilot-file-manager'
)
if (!isImageFileType(file.type) && !isCopilotSupported(file.type)) {
const resolvedType = resolveFileType(file)
if (!isImageFileType(resolvedType) && !isCopilotSupported(resolvedType)) {
throw new InvalidRequestError(
'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
)

View File

@@ -2,10 +2,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { FileText } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Skeleton } from '@/components/emcn'
import { PanelLeft } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useSession } from '@/lib/auth/auth-client'
import {
LandingPromptStorage,
@@ -45,6 +45,21 @@ function ThinkingIndicator() {
)
}
interface FileAttachmentPillProps {
mediaType: string
filename: string
}
function FileAttachmentPill({ mediaType, filename }: FileAttachmentPillProps) {
const Icon = getDocumentIcon(mediaType, filename)
return (
<div className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<span className='truncate text-[11px] text-[var(--text-secondary)]'>{filename}</span>
</div>
)
}
const SKELETON_LINE_COUNT = 4
function ChatSkeleton({ children }: { children: React.ReactNode }) {
@@ -235,10 +250,10 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div className='relative flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='relative flex h-full min-w-0 flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 py-4'
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-[98px]'
>
<div className='mx-auto max-w-[42rem] space-y-6'>
{messages.map((msg, index) => {
@@ -262,15 +277,11 @@ export function Home({ chatId }: HomeProps = {}) {
/>
</div>
) : (
<div
<FileAttachmentPill
key={att.id}
className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'
>
<FileText className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<span className='truncate text-[11px] text-[var(--text-secondary)]'>
{att.filename}
</span>
</div>
mediaType={att.media_type}
filename={att.filename}
/>
)
})}
</div>
@@ -317,14 +328,17 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
</div>
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
<UserInput
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
/>
<div className='pointer-events-none absolute right-0 bottom-0 left-0 z-10 px-[24px] pb-[16px]'>
<div className='pointer-events-auto relative mx-auto max-w-[42rem]'>
<div className='-top-px -right-px -left-px -bottom-[16px] -z-10 absolute rounded-t-[20px] bg-[var(--bg)]' />
<UserInput
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
/>
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('useFileAttachments')
@@ -117,11 +118,13 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
previewUrl = URL.createObjectURL(file)
}
const resolvedType = resolveFileType(file)
const tempFile: AttachedFile = {
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
type: resolvedType,
path: '',
uploading: true,
previewUrl,

View File

@@ -5,6 +5,7 @@ import {
generatePresignedDownloadUrl,
generatePresignedUploadUrl,
} from '@/lib/uploads/core/storage-service'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import type { PresignedUrlResponse } from '@/lib/uploads/shared/types'
const logger = createLogger('CopilotFileManager')
@@ -29,19 +30,12 @@ const SUPPORTED_FILE_TYPES = [
]
/**
* Check if a file type is supported for copilot attachments
* Check if a MIME type is supported for copilot attachments
*/
export function isSupportedFileType(mimeType: string): boolean {
return SUPPORTED_FILE_TYPES.includes(mimeType.toLowerCase())
}
/**
* Check if a content type is an image
*/
export function isImageFileType(contentType: string): boolean {
return contentType.toLowerCase().startsWith('image/')
}
export interface CopilotFileAttachment {
key: string
filename: string

View File

@@ -5,7 +5,6 @@ export {
type GenerateCopilotUploadUrlOptions,
generateCopilotDownloadUrl,
generateCopilotUploadUrl,
isImageFileType,
isSupportedFileType,
processCopilotAttachments,
} from './copilot-file-manager'

View File

@@ -165,56 +165,126 @@ export function getFileExtension(filename: string): string {
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
}
const EXTENSION_TO_MIME: Record<string, string> = {
// Images
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
// Documents
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
html: 'text/html',
htm: 'text/html',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
doc: 'application/msword',
xls: 'application/vnd.ms-excel',
ppt: 'application/vnd.ms-powerpoint',
md: 'text/markdown',
yaml: 'application/x-yaml',
yml: 'application/x-yaml',
rtf: 'application/rtf',
// Audio
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
webm: 'audio/webm',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
opus: 'audio/opus',
// Video
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
}
/**
* Get MIME type from file extension (fallback if not provided)
*/
export function getMimeTypeFromExtension(extension: string): string {
const extensionMimeMap: Record<string, string> = {
// Images
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
return EXTENSION_TO_MIME[extension.toLowerCase()] || 'application/octet-stream'
}
// Documents
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
html: 'text/html',
htm: 'text/html',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
doc: 'application/msword',
xls: 'application/vnd.ms-excel',
ppt: 'application/vnd.ms-powerpoint',
md: 'text/markdown',
yaml: 'application/x-yaml',
yml: 'application/x-yaml',
rtf: 'application/rtf',
/**
* Resolve a reliable MIME type from a file, falling back to extension
* when the browser reports empty or generic `application/octet-stream`
*/
export function resolveFileType(file: { type: string; name: string }): string {
return file.type && file.type !== 'application/octet-stream'
? file.type
: getMimeTypeFromExtension(getFileExtension(file.name))
}
// Audio
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
webm: 'audio/webm',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
opus: 'audio/opus',
const MIME_TO_EXTENSION: Record<string, string> = {
// Images
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
// Video
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
}
// Documents
'application/pdf': 'pdf',
'text/plain': 'txt',
'text/csv': 'csv',
'application/json': 'json',
'application/xml': 'xml',
'text/xml': 'xml',
'text/html': 'html',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/msword': 'doc',
'application/vnd.ms-excel': 'xls',
'application/vnd.ms-powerpoint': 'ppt',
'text/markdown': 'md',
'application/rtf': 'rtf',
return extensionMimeMap[extension.toLowerCase()] || 'application/octet-stream'
// Audio
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a',
'audio/m4a': 'm4a',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/x-wav': 'wav',
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/vorbis': 'ogg',
'audio/flac': 'flac',
'audio/x-flac': 'flac',
'audio/aac': 'aac',
'audio/x-aac': 'aac',
'audio/opus': 'opus',
// Video
'video/mp4': 'mp4',
'video/mpeg': 'mpg',
'video/quicktime': 'mov',
'video/x-quicktime': 'mov',
'video/x-msvideo': 'avi',
'video/avi': 'avi',
'video/x-matroska': 'mkv',
'video/webm': 'webm',
// Archives
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/gzip': 'gz',
}
/**
@@ -223,67 +293,7 @@ export function getMimeTypeFromExtension(extension: string): string {
* @returns File extension without dot, or null if not found
*/
export function getExtensionFromMimeType(mimeType: string): string | null {
const mimeToExtension: Record<string, string> = {
// Images
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
// Documents
'application/pdf': 'pdf',
'text/plain': 'txt',
'text/csv': 'csv',
'application/json': 'json',
'application/xml': 'xml',
'text/xml': 'xml',
'text/html': 'html',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/msword': 'doc',
'application/vnd.ms-excel': 'xls',
'application/vnd.ms-powerpoint': 'ppt',
'text/markdown': 'md',
'application/rtf': 'rtf',
// Audio
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a',
'audio/m4a': 'm4a',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/x-wav': 'wav',
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/vorbis': 'ogg',
'audio/flac': 'flac',
'audio/x-flac': 'flac',
'audio/aac': 'aac',
'audio/x-aac': 'aac',
'audio/opus': 'opus',
// Video
'video/mp4': 'mp4',
'video/mpeg': 'mpg',
'video/quicktime': 'mov',
'video/x-quicktime': 'mov',
'video/x-msvideo': 'avi',
'video/avi': 'avi',
'video/x-matroska': 'mkv',
'video/webm': 'webm',
// Archives
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/gzip': 'gz',
}
return mimeToExtension[mimeType.toLowerCase()] || null
return MIME_TO_EXTENSION[mimeType.toLowerCase()] || null
}
/**