diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index 75c387b6a52..5b6a4455d0e 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -458,10 +458,10 @@ describe('File Upload Security Tests', () => { expect(response.status).toBe(200) }) - it('should reject JavaScript files', async () => { + it('should reject unsupported file types', async () => { const formData = new FormData() - const maliciousJs = 'alert("XSS")' - const file = new File([maliciousJs], 'malicious.js', { type: 'application/javascript' }) + const content = 'binary data' + const file = new File([content], 'archive.exe', { type: 'application/octet-stream' }) formData.append('file', file) formData.append('context', 'workspace') formData.append('workspaceId', 'test-workspace-id') @@ -475,7 +475,7 @@ describe('File Upload Security Tests', () => { expect(response.status).toBe(400) const data = await response.json() - expect(data.message).toContain("File type 'js' is not allowed") + expect(data.message).toContain("File type 'exe' is not allowed") }) it('should reject files without extensions', async () => { diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 387a2ccee82..b6791a3841b 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -8,6 +8,7 @@ import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/works import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_AUDIO_EXTENSIONS, + SUPPORTED_CODE_EXTENSIONS, SUPPORTED_DOCUMENT_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS, validateFileType, @@ -23,6 +24,7 @@ const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] as const const ALLOWED_EXTENSIONS = new Set([ ...SUPPORTED_DOCUMENT_EXTENSIONS, + ...SUPPORTED_CODE_EXTENSIONS, ...IMAGE_EXTENSIONS, ...SUPPORTED_AUDIO_EXTENSIONS, ...SUPPORTED_VIDEO_EXTENSIONS, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx new file mode 100644 index 00000000000..5e31edcfb55 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx @@ -0,0 +1,38 @@ +import { memo } from 'react' + +interface DataTableProps { + headers: string[] + rows: string[][] +} + +export const DataTable = memo(function DataTable({ headers, rows }: DataTableProps) { + return ( +
+ + + + {headers.map((header, i) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {headers.map((_, ci) => ( + + ))} + + ))} + +
+ {String(header ?? '')} +
+ {String(row[ci] ?? '')} +
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index fe8660aa464..f62caa1f51c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -7,6 +7,7 @@ import { Skeleton } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' import { useUpdateWorkspaceFileContent, useWorkspaceFileBinary, @@ -14,6 +15,7 @@ import { } from '@/hooks/queries/workspace-files' import { useAutosave } from '@/hooks/use-autosave' import { useStreamingText } from '@/hooks/use-streaming-text' +import { DataTable } from './data-table' import { PreviewPanel, resolvePreviewType } from './preview-panel' const logger = createLogger('FileViewer') @@ -29,6 +31,16 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([ 'application/x-yaml', 'text/csv', 'text/html', + 'text/xml', + 'application/xml', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/typescript', + 'application/toml', + 'text/x-python', + 'text/x-sh', + 'text/x-sql', 'image/svg+xml', ]) @@ -42,6 +54,7 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([ 'html', 'htm', 'svg', + ...SUPPORTED_CODE_EXTENSIONS, ]) const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf']) @@ -55,11 +68,23 @@ const PPTX_PREVIEWABLE_MIME_TYPES = new Set([ ]) const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx']) +const DOCX_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]) +const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx']) + +const XLSX_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +]) +const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx']) + type FileCategory = | 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'pptx-previewable' + | 'docx-previewable' + | 'xlsx-previewable' | 'unsupported' function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { @@ -67,12 +92,17 @@ function resolveFileCategory(mimeType: string | null, filename: string): FileCat if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable' + if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable' + if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable' const ext = getFileExtension(filename) - if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable' + const nameKey = ext || filename.toLowerCase() + if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable' if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable' if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable' if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable' + if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable' + if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable' return 'unsupported' } @@ -142,6 +172,14 @@ export function FileViewer({ return } + if (category === 'docx-previewable') { + return + } + + if (category === 'xlsx-previewable') { + return + } + return } @@ -339,16 +377,7 @@ function TextEditor({ }, [isStreaming, revealedContent]) if (streamingContent === undefined) { - if (isLoading) { - return ( -
- - - - -
- ) - } + if (isLoading) return DOCUMENT_SKELETON if (error) { return ( @@ -551,6 +580,29 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR ) }) +function resolvePreviewError(fetchError: Error | null, renderError: string | null): string | null { + if (fetchError) return fetchError.message + return renderError +} + +function PreviewError({ label, error }: { label: string; error: string }) { + return ( +
+

Failed to preview {label}

+

{error}

+
+ ) +} + +const DOCUMENT_SKELETON = ( +
+ + + + +
+) + const pptxSlideCache = new Map() function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string { @@ -769,23 +821,10 @@ function PptxPreview({ } }, [fileData, dataUpdatedAt, streamingContent, cacheKey, workspaceId]) - const error = fetchError - ? fetchError instanceof Error - ? fetchError.message - : 'Failed to load file' - : renderError + const error = resolvePreviewError(fetchError, renderError) const loading = isFetching || rendering - if (error) { - return ( -
-

- Failed to preview presentation -

-

{error}

-
- ) - } + if (error) return if (loading && slides.length === 0) { return ( @@ -826,6 +865,211 @@ function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: }) } +const DocxPreview = memo(function DocxPreview({ + file, + workspaceId, +}: { + file: WorkspaceFileRecord + workspaceId: string +}) { + const { + data: fileData, + isLoading, + error: fetchError, + } = useWorkspaceFileBinary(workspaceId, file.id, file.key) + + const [html, setHtml] = useState(null) + const [renderError, setRenderError] = useState(null) + + useEffect(() => { + if (!fileData) return + const data = fileData + + let cancelled = false + + async function convert() { + try { + setRenderError(null) + const mammoth = await import('mammoth') + const result = await mammoth.convertToHtml({ arrayBuffer: data }) + if (!cancelled) setHtml(result.value) + } catch (err) { + if (!cancelled) { + const msg = err instanceof Error ? err.message : 'Failed to render document' + logger.error('DOCX render failed', { error: msg }) + setRenderError(msg) + } + } + } + + convert() + return () => { + cancelled = true + } + }, [fileData]) + + const error = resolvePreviewError(fetchError, renderError) + if (error) return + if (isLoading || html === null) return DOCUMENT_SKELETON + + return ( +
+