mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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).'
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,6 @@ export {
|
||||
type GenerateCopilotUploadUrlOptions,
|
||||
generateCopilotDownloadUrl,
|
||||
generateCopilotUploadUrl,
|
||||
isImageFileType,
|
||||
isSupportedFileType,
|
||||
processCopilotAttachments,
|
||||
} from './copilot-file-manager'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user