Skip to content

Commit e0af69c

Browse files
waleedlatif1claude
andcommitted
fix: SVG file support in mothership chat and file serving
- Send SVGs as document/text-xml to Claude instead of unsupported image/svg+xml, so the mothership can actually read SVG content - Serve SVGs inline with proper content type and CSP sandbox so chat previews render correctly - Add SVG preview support in file viewer (sandboxed iframe) - Derive IMAGE_MIME_TYPES from MIME_TYPE_MAPPING to reduce duplication - Add missing webp to contentTypeMap, SAFE_INLINE_TYPES, binaryExtensions - Consolidate PREVIEWABLE_EXTENSIONS into preview-panel exports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7ad813b commit e0af69c

File tree

7 files changed

+111
-49
lines changed

7 files changed

+111
-49
lines changed

apps/sim/app/api/files/utils.test.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,7 @@ describe('extractFilename', () => {
170170
'inline; filename="safe-image.png"'
171171
)
172172
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
173-
expect(response.headers.get('Content-Security-Policy')).toBe(
174-
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
175-
)
173+
expect(response.headers.get('Content-Security-Policy')).toBeNull()
176174
})
177175

178176
it('should serve PDFs inline safely', () => {
@@ -203,33 +201,31 @@ describe('extractFilename', () => {
203201
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
204202
})
205203

206-
it('should force attachment for SVG files to prevent XSS', () => {
204+
it('should serve SVG files inline with CSP sandbox protection', () => {
207205
const response = createFileResponse({
208206
buffer: Buffer.from(
209207
'<svg onload="alert(\'XSS\')" xmlns="http://www.w3.org/2000/svg"></svg>'
210208
),
211209
contentType: 'image/svg+xml',
212-
filename: 'malicious.svg',
210+
filename: 'image.svg',
213211
})
214212

215213
expect(response.status).toBe(200)
216-
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
217-
expect(response.headers.get('Content-Disposition')).toBe(
218-
'attachment; filename="malicious.svg"'
214+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml')
215+
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="image.svg"')
216+
expect(response.headers.get('Content-Security-Policy')).toBe(
217+
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
219218
)
220219
})
221220

222-
it('should override dangerous content types to safe alternatives', () => {
221+
it('should not apply CSP sandbox to non-SVG files', () => {
223222
const response = createFileResponse({
224-
buffer: Buffer.from('<svg>safe content</svg>'),
225-
contentType: 'image/svg+xml',
226-
filename: 'image.png', // Extension doesn't match content-type
223+
buffer: Buffer.from('hello'),
224+
contentType: 'text/plain',
225+
filename: 'readme.txt',
227226
})
228227

229-
expect(response.status).toBe(200)
230-
// Should override SVG content type to plain text for safety
231-
expect(response.headers.get('Content-Type')).toBe('text/plain')
232-
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="image.png"')
228+
expect(response.headers.get('Content-Security-Policy')).toBeNull()
233229
})
234230

235231
it('should force attachment for JavaScript files', () => {
@@ -302,15 +298,22 @@ describe('extractFilename', () => {
302298
})
303299

304300
describe('Content Security Policy', () => {
305-
it('should include CSP header in all responses', () => {
306-
const response = createFileResponse({
301+
it('should include CSP header only for SVG responses', () => {
302+
const svgResponse = createFileResponse({
303+
buffer: Buffer.from('<svg></svg>'),
304+
contentType: 'image/svg+xml',
305+
filename: 'icon.svg',
306+
})
307+
expect(svgResponse.headers.get('Content-Security-Policy')).toBe(
308+
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
309+
)
310+
311+
const txtResponse = createFileResponse({
307312
buffer: Buffer.from('test'),
308313
contentType: 'text/plain',
309314
filename: 'test.txt',
310315
})
311-
312-
const csp = response.headers.get('Content-Security-Policy')
313-
expect(csp).toBe("default-src 'none'; style-src 'unsafe-inline'; sandbox;")
316+
expect(txtResponse.headers.get('Content-Security-Policy')).toBeNull()
314317
})
315318

316319
it('should include X-Content-Type-Options header', () => {

apps/sim/app/api/files/utils.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const contentTypeMap: Record<string, string> = {
6161
jpg: 'image/jpeg',
6262
jpeg: 'image/jpeg',
6363
gif: 'image/gif',
64+
svg: 'image/svg+xml',
65+
webp: 'image/webp',
6466
zip: 'application/zip',
6567
googleFolder: 'application/vnd.google-apps.folder',
6668
}
@@ -77,6 +79,7 @@ export const binaryExtensions = [
7779
'jpg',
7880
'jpeg',
7981
'gif',
82+
'webp',
8083
'pdf',
8184
]
8285

@@ -204,13 +207,15 @@ const SAFE_INLINE_TYPES = new Set([
204207
'image/jpeg',
205208
'image/jpg',
206209
'image/gif',
210+
'image/svg+xml',
211+
'image/webp',
207212
'application/pdf',
208213
'text/plain',
209214
'text/csv',
210215
'application/json',
211216
])
212217

213-
const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'svg', 'js', 'css', 'xml'])
218+
const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml'])
214219

215220
function getSecureFileHeaders(filename: string, originalContentType: string) {
216221
const extension = filename.split('.').pop()?.toLowerCase() || ''
@@ -224,7 +229,7 @@ function getSecureFileHeaders(filename: string, originalContentType: string) {
224229

225230
let safeContentType = originalContentType
226231

227-
if (originalContentType === 'text/html' || originalContentType === 'image/svg+xml') {
232+
if (originalContentType === 'text/html') {
228233
safeContentType = 'text/plain'
229234
}
230235

@@ -253,16 +258,18 @@ function encodeFilenameForHeader(storageKey: string): string {
253258
export function createFileResponse(file: FileResponse): NextResponse {
254259
const { contentType, disposition } = getSecureFileHeaders(file.filename, file.contentType)
255260

256-
return new NextResponse(file.buffer as BodyInit, {
257-
status: 200,
258-
headers: {
259-
'Content-Type': contentType,
260-
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`,
261-
'Cache-Control': file.cacheControl || 'public, max-age=31536000',
262-
'X-Content-Type-Options': 'nosniff',
263-
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox;",
264-
},
265-
})
261+
const headers: Record<string, string> = {
262+
'Content-Type': contentType,
263+
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`,
264+
'Cache-Control': file.cacheControl || 'public, max-age=31536000',
265+
'X-Content-Type-Options': 'nosniff',
266+
}
267+
268+
if (contentType === 'image/svg+xml') {
269+
headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox;"
270+
}
271+
272+
return new NextResponse(file.buffer as BodyInit, { status: 200, headers })
266273
}
267274

268275
export function createErrorResponse(error: Error, status = 500): NextResponse {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([
2626
'application/x-yaml',
2727
'text/csv',
2828
'text/html',
29+
'image/svg+xml',
2930
])
3031

31-
const TEXT_EDITABLE_EXTENSIONS = new Set(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm'])
32+
const TEXT_EDITABLE_EXTENSIONS = new Set([
33+
'md',
34+
'txt',
35+
'json',
36+
'yaml',
37+
'yml',
38+
'csv',
39+
'html',
40+
'htm',
41+
'svg',
42+
])
3243

3344
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf'])
3445
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export type { PreviewMode } from './file-viewer'
22
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'
3+
export { PREVIEW_ONLY_EXTENSIONS, RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,29 @@ import remarkBreaks from 'remark-breaks'
66
import remarkGfm from 'remark-gfm'
77
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
88

9-
type PreviewType = 'markdown' | 'html' | 'csv' | null
9+
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
1010

1111
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
1212
'text/markdown': 'markdown',
1313
'text/html': 'html',
1414
'text/csv': 'csv',
15+
'image/svg+xml': 'svg',
1516
}
1617

1718
const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
1819
md: 'markdown',
1920
html: 'html',
2021
htm: 'html',
2122
csv: 'csv',
23+
svg: 'svg',
2224
}
2325

26+
/** Extensions that should default to rendered preview (no raw editor). */
27+
export const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm', 'svg'])
28+
29+
/** All extensions that have a rich preview renderer. */
30+
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))
31+
2432
export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType {
2533
if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType]
2634
const ext = getFileExtension(filename)
@@ -39,6 +47,7 @@ export function PreviewPanel({ content, mimeType, filename }: PreviewPanelProps)
3947
if (previewType === 'markdown') return <MarkdownPreview content={content} />
4048
if (previewType === 'html') return <HtmlPreview content={content} />
4149
if (previewType === 'csv') return <CsvPreview content={content} />
50+
if (previewType === 'svg') return <SvgPreview content={content} />
4251

4352
return null
4453
}
@@ -175,6 +184,25 @@ function HtmlPreview({ content }: { content: string }) {
175184
)
176185
}
177186

187+
function SvgPreview({ content }: { content: string }) {
188+
const wrappedContent = useMemo(
189+
() =>
190+
`<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`,
191+
[content]
192+
)
193+
194+
return (
195+
<div className='h-full overflow-hidden'>
196+
<iframe
197+
srcDoc={wrappedContent}
198+
sandbox=''
199+
title='SVG Preview'
200+
className='h-full w-full border-0'
201+
/>
202+
</div>
203+
)
204+
}
205+
178206
function CsvPreview({ content }: { content: string }) {
179207
const { headers, rows } = useMemo(() => parseCsv(content), [content])
180208

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import { useCallback, useEffect, useState } from 'react'
44
import { cn } from '@/lib/core/utils/cn'
55
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
66
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
7+
import {
8+
PREVIEW_ONLY_EXTENSIONS,
9+
RICH_PREVIEWABLE_EXTENSIONS,
10+
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
711
import type {
812
MothershipResource,
913
MothershipResourceType,
1014
} from '@/app/workspace/[workspaceId]/home/types'
1115
import { ResourceActions, ResourceContent, ResourceTabs } from './components'
1216

13-
const PREVIEWABLE_EXTENSIONS = new Set(['md', 'html', 'htm', 'csv'])
14-
const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm'])
15-
1617
const PREVIEW_CYCLE: Record<PreviewMode, PreviewMode> = {
1718
editor: 'split',
1819
split: 'preview',
@@ -57,7 +58,7 @@ export function MothershipView({
5758
}, [active?.id])
5859

5960
const isActivePreviewable =
60-
active?.type === 'file' && PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
61+
active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
6162

6263
return (
6364
<div

apps/sim/lib/uploads/utils/file-utils.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document' | 'audio' |
3232
'image/png': 'image',
3333
'image/gif': 'image',
3434
'image/webp': 'image',
35-
'image/svg+xml': 'image',
35+
// SVG is XML text, not a raster image — handled separately in createFileContent
3636

3737
// Documents
3838
'application/pdf': 'document',
@@ -97,16 +97,14 @@ export function isSupportedFileType(mimeType: string): boolean {
9797
/**
9898
* Check if a MIME type is an image type (for copilot uploads)
9999
*/
100+
const IMAGE_MIME_TYPES = new Set(
101+
Object.entries(MIME_TYPE_MAPPING)
102+
.filter(([, v]) => v === 'image')
103+
.map(([k]) => k)
104+
)
105+
100106
export function isImageFileType(mimeType: string): boolean {
101-
const imageTypes = [
102-
'image/jpeg',
103-
'image/jpg',
104-
'image/png',
105-
'image/gif',
106-
'image/webp',
107-
'image/svg+xml',
108-
]
109-
return imageTypes.includes(mimeType.toLowerCase())
107+
return IMAGE_MIME_TYPES.has(mimeType.toLowerCase())
110108
}
111109

112110
/**
@@ -142,6 +140,19 @@ export function bufferToBase64(buffer: Buffer): string {
142140
* Create message content from file data
143141
*/
144142
export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null {
143+
// SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP),
144+
// so send SVGs as an XML document instead
145+
if (mimeType.toLowerCase() === 'image/svg+xml') {
146+
return {
147+
type: 'document',
148+
source: {
149+
type: 'base64',
150+
media_type: 'text/xml',
151+
data: bufferToBase64(fileBuffer),
152+
},
153+
}
154+
}
155+
145156
const contentType = getContentType(mimeType)
146157
if (!contentType) {
147158
return null

0 commit comments

Comments
 (0)