Skip to content

Commit 191f1e6

Browse files
committed
fix(files): render audio and video stored as application/octet-stream
The file viewer built the blob backing <audio>/<video> from the record's stored content type with a truthiness fallback, so a stored application/octet-stream was passed straight through and the element could not determine the format. Downloading the same file worked because the download path derives its content type from the filename. - Add resolveEffectiveMimeType, which resolves a generic stored type against the filename, and use it for the media blob, the type column, and the type filter (an octet-stream video was also invisible to the Audio/Video/Image filters) - Map .webm to video/webm rather than audio/webm: a <video> element plays an audio-only stream, an <audio> element drops the picture - Preview .bmp, .avif and .ico, which upload accepts but the viewer sent to the download-only path; serve them with their real content type so nosniff does not block them. .tiff and .heic stay unsupported - no browser renders them - Open .jsonl in the text editor, and fill the extension-to-mime gaps for .mmd, .diff, .patch and .fish
1 parent 0ae09c1 commit 191f1e6

7 files changed

Lines changed: 185 additions & 12 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export const contentTypeMap: Record<string, string> = {
6060
gif: 'image/gif',
6161
svg: 'image/svg+xml',
6262
webp: 'image/webp',
63+
avif: 'image/avif',
64+
bmp: 'image/bmp',
65+
ico: 'image/x-icon',
6366
zip: 'application/zip',
6467
googleFolder: 'application/vnd.google-apps.folder',
6568
}
@@ -159,6 +162,9 @@ const SAFE_INLINE_TYPES = new Set([
159162
'image/gif',
160163
'image/svg+xml',
161164
'image/webp',
165+
'image/avif',
166+
'image/bmp',
167+
'image/x-icon',
162168
'application/pdf',
163169
'text/plain',
164170
'text/csv',

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,29 @@ describe('resolveFileCategory — MIME priority', () => {
222222
})
223223
})
224224

225+
describe('resolveFileCategory — formats accepted on upload must be previewable', () => {
226+
it.each([
227+
['image.bmp', 'image/bmp'],
228+
['image.avif', 'image/avif'],
229+
['favicon.ico', 'image/x-icon'],
230+
])('%s previews as an image', (filename, mimeType) => {
231+
expect(resolveFileCategory(mimeType, filename)).toBe('image-previewable')
232+
expect(resolveFileCategory('application/octet-stream', filename)).toBe('image-previewable')
233+
})
234+
235+
it.each(['image.tiff', 'photo.heic'])(
236+
'%s stays unsupported — no browser renders it in an <img>',
237+
(filename) => {
238+
expect(resolveFileCategory(null, filename)).toBe('unsupported')
239+
}
240+
)
241+
242+
it('.jsonl opens in the text editor', () => {
243+
expect(resolveFileCategory('application/jsonl', 'events.jsonl')).toBe('text-editable')
244+
expect(resolveFileCategory('application/octet-stream', 'events.jsonl')).toBe('text-editable')
245+
})
246+
})
247+
225248
describe('resolveFileCategory — extension case', () => {
226249
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
227250
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([
55
'text/markdown',
66
'text/plain',
77
'application/json',
8+
'application/jsonl',
89
'application/x-yaml',
910
'text/csv',
1011
'text/html',
@@ -26,6 +27,7 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([
2627
'md',
2728
'txt',
2829
'json',
30+
'jsonl',
2931
'yaml',
3032
'yml',
3133
'csv',
@@ -43,8 +45,32 @@ const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
4345
])
4446
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4547

46-
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
47-
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
48+
/**
49+
* Image formats every supported browser decodes natively. `.tif`/`.tiff` and
50+
* `.heic`/`.heif` are accepted uploads but deliberately absent — no browser renders
51+
* them in an `<img>`, so they stay on the download-only path rather than showing a
52+
* broken image.
53+
*/
54+
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
55+
'image/png',
56+
'image/jpeg',
57+
'image/gif',
58+
'image/webp',
59+
'image/avif',
60+
'image/bmp',
61+
'image/x-icon',
62+
'image/vnd.microsoft.icon',
63+
])
64+
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
65+
'png',
66+
'jpg',
67+
'jpeg',
68+
'gif',
69+
'webp',
70+
'avif',
71+
'bmp',
72+
'ico',
73+
])
4874

4975
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
5076
'audio/mpeg',

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { Music } from '@sim/emcn/icons'
55
import dynamic from 'next/dynamic'
66
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
7-
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
7+
import { getFileExtension, resolveEffectiveMimeType } from '@/lib/uploads/utils/file-utils'
88
import {
99
useWorkspaceFileBinary,
1010
useWorkspaceFileContent,
@@ -363,6 +363,28 @@ function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
363363

364364
const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const
365365

366+
/**
367+
* The `type` for the blob backing an `<audio>`/`<video>` element. That type is the only
368+
* format signal the element gets, so it has to be resolved against the filename rather
369+
* than read off the record: storage legitimately holds `application/octet-stream` (the
370+
* browser reports it for plenty of media formats, and the presigned PUT handshake
371+
* requires persisting it verbatim), and an element handed that type cannot determine the
372+
* format and renders nothing — which is why the same file downloads perfectly.
373+
*
374+
* Anything that does not resolve to a playable media type falls back to the kind's
375+
* default rather than being passed through, so a stale or mismatched stored type can
376+
* never leave the element with a type it cannot play.
377+
*/
378+
function resolveMediaBlobType(
379+
storedType: string | null,
380+
filename: string,
381+
kind: 'audio' | 'video'
382+
): string {
383+
const resolved = resolveEffectiveMimeType(storedType, filename)
384+
if (resolved?.startsWith('audio/') || resolved?.startsWith('video/')) return resolved
385+
return MEDIA_FALLBACK_MIME[kind]
386+
}
387+
366388
/**
367389
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
368390
* lifecycle, and error/loading handling are identical; only the rendered
@@ -385,12 +407,12 @@ const MediaPreview = memo(function MediaPreview({
385407
replaceBlobUrl,
386408
} = useBlobUrl(workspaceId, file.id, file.key)
387409

410+
const mediaType = resolveMediaBlobType(file.type, file.name, kind)
411+
388412
useEffect(() => {
389413
if (!fileData) return
390-
replaceBlobUrl(
391-
URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] }))
392-
)
393-
}, [file.type, fileData, kind, replaceBlobUrl])
414+
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: mediaType })))
415+
}, [fileData, mediaType, replaceBlobUrl])
394416

395417
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
396418
if (error) return <PreviewError label={kind} error={error} />

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
getMimeTypeFromExtension,
3737
isAudioFileType,
3838
isVideoFileType,
39+
resolveEffectiveMimeType,
3940
} from '@/lib/uploads/utils/file-utils'
4041
import {
4142
isSupportedExtension,
@@ -195,7 +196,11 @@ const parseRowId = (rowId: string): { kind: 'file' | 'folder'; id: string } => {
195196
const hasExternalFiles = (dataTransfer: DataTransfer): boolean =>
196197
dataTransfer.types.includes('Files')
197198

198-
function formatFileType(mimeType: string | null, filename: string): string {
199+
function formatFileType(storedType: string | null, filename: string): string {
200+
// A stored `application/octet-stream` labels nothing and matches no filter, so the
201+
// effective type is resolved from the filename first — see resolveEffectiveMimeType.
202+
const mimeType = resolveEffectiveMimeType(storedType, filename)
203+
199204
if (mimeType && MIME_TYPE_LABELS[mimeType]) {
200205
return MIME_TYPE_LABELS[mimeType]
201206
}
@@ -493,10 +498,13 @@ export function Files() {
493498
if (typeFilter.length > 0) {
494499
result = result.filter((f) => {
495500
const ext = getFileExtension(f.name)
501+
// Matching on the raw stored type would hide every file the browser uploaded as
502+
// `application/octet-stream` from the audio/video/image filters.
503+
const type = resolveEffectiveMimeType(f.type, f.name) ?? ''
496504
if (typeFilter.includes('document') && isSupportedExtension(ext)) return true
497-
if (typeFilter.includes('audio') && isAudioFileType(f.type)) return true
498-
if (typeFilter.includes('video') && isVideoFileType(f.type)) return true
499-
if (typeFilter.includes('image') && f.type?.startsWith('image/')) return true
505+
if (typeFilter.includes('audio') && isAudioFileType(type)) return true
506+
if (typeFilter.includes('video') && isVideoFileType(type)) return true
507+
if (typeFilter.includes('image') && type.startsWith('image/')) return true
500508
return false
501509
})
502510
}

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isMarkdownFile,
1212
isNetworkError,
1313
processSingleFileToUserFile,
14+
resolveEffectiveMimeType,
1415
resolveTrustedFileContext,
1516
} from '@/lib/uploads/utils/file-utils'
1617

@@ -193,3 +194,48 @@ describe('processSingleFileToUserFile', () => {
193194
expect(result.key).toBe('workspace/ws-1/doc.pdf')
194195
})
195196
})
197+
198+
describe('resolveEffectiveMimeType', () => {
199+
it('keeps a specific stored type', () => {
200+
expect(resolveEffectiveMimeType('video/quicktime', 'clip.mp4')).toBe('video/quicktime')
201+
expect(resolveEffectiveMimeType('text/markdown', 'notes.md')).toBe('text/markdown')
202+
})
203+
204+
it.each([
205+
['clip.mp4', 'video/mp4'],
206+
['clip.mov', 'video/quicktime'],
207+
['clip.mkv', 'video/x-matroska'],
208+
['song.mp3', 'audio/mpeg'],
209+
['song.flac', 'audio/flac'],
210+
['icon.ico', 'image/x-icon'],
211+
['shot.avif', 'image/avif'],
212+
])('resolves a stored application/octet-stream for %s from the extension', (name, expected) => {
213+
expect(resolveEffectiveMimeType('application/octet-stream', name)).toBe(expected)
214+
})
215+
216+
it('resolves binary/octet-stream and blank stored types too', () => {
217+
expect(resolveEffectiveMimeType('binary/octet-stream', 'clip.mp4')).toBe('video/mp4')
218+
expect(resolveEffectiveMimeType(' ', 'clip.mp4')).toBe('video/mp4')
219+
expect(resolveEffectiveMimeType(null, 'clip.mp4')).toBe('video/mp4')
220+
expect(resolveEffectiveMimeType(undefined, 'clip.mp4')).toBe('video/mp4')
221+
})
222+
223+
it('resolves .webm to the video type so the picture is not dropped', () => {
224+
expect(resolveEffectiveMimeType('application/octet-stream', 'clip.webm')).toBe('video/webm')
225+
})
226+
227+
it('keeps an explicit audio/webm from the browser', () => {
228+
expect(resolveEffectiveMimeType('audio/webm', 'recording.webm')).toBe('audio/webm')
229+
})
230+
231+
it('returns the generic type when the extension identifies nothing either', () => {
232+
expect(resolveEffectiveMimeType('application/octet-stream', 'firmware.bin')).toBe(
233+
'application/octet-stream'
234+
)
235+
})
236+
237+
it('returns null when there is neither a stored type nor a known extension', () => {
238+
expect(resolveEffectiveMimeType(null, 'firmware.bin')).toBeNull()
239+
expect(resolveEffectiveMimeType('', 'noextension')).toBeNull()
240+
})
241+
})

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ const EXTENSION_TO_MIME: Record<string, string> = {
312312
xls: 'application/vnd.ms-excel',
313313
ppt: 'application/vnd.ms-powerpoint',
314314
md: 'text/markdown',
315+
jsonl: 'application/jsonl',
315316
yaml: 'application/x-yaml',
316317
yml: 'application/x-yaml',
317318
rtf: 'application/rtf',
@@ -362,12 +363,15 @@ const EXTENSION_TO_MIME: Record<string, string> = {
362363
graphql: 'text/x-graphql',
363364
gql: 'text/x-graphql',
364365
proto: 'text/x-protobuf',
366+
mmd: 'text/x-mermaid',
367+
diff: 'text/x-diff',
368+
patch: 'text/x-diff',
369+
fish: 'text/x-shellscript',
365370

366371
// Audio
367372
mp3: 'audio/mpeg',
368373
m4a: 'audio/mp4',
369374
wav: 'audio/wav',
370-
webm: 'audio/webm',
371375
ogg: 'audio/ogg',
372376
flac: 'audio/flac',
373377
aac: 'audio/aac',
@@ -378,8 +382,20 @@ const EXTENSION_TO_MIME: Record<string, string> = {
378382
mov: 'video/quicktime',
379383
avi: 'video/x-msvideo',
380384
mkv: 'video/x-matroska',
385+
// `.webm` is both an audio and a video container; the video type is the safe
386+
// resolution because a `<video>` element plays an audio-only stream, while an
387+
// `<audio>` element handed a video stream drops the picture.
388+
webm: 'video/webm',
381389
}
382390

391+
/**
392+
* MIME types that carry no format information. Storage keeps whatever the browser
393+
* reported at upload time, and the direct-PUT path preserves it verbatim (see
394+
* {@link getFileContentType}), so a stored type may be one of these even when the
395+
* filename identifies the format precisely.
396+
*/
397+
const GENERIC_MIME_TYPES = new Set(['application/octet-stream', 'binary/octet-stream'])
398+
383399
/**
384400
* Get MIME type from file extension (fallback if not provided)
385401
*/
@@ -416,6 +432,32 @@ export function getFileContentType(file: File): string {
416432
return resolveFileType(file, { preserveOctetStream: true })
417433
}
418434

435+
/**
436+
* The MIME type to render a *stored* file as, resolving the generic types that storage
437+
* legitimately holds against the filename.
438+
*
439+
* A stored `application/octet-stream` is not an error — browsers report it for plenty of
440+
* real formats, and the presigned PUT handshake requires persisting it verbatim. Any
441+
* consumer that feeds a stored type to a `Blob`, a media element, or a type filter must
442+
* go through this rather than trusting `file.type` directly: a truthiness check
443+
* (`file.type || fallback`) passes `application/octet-stream` straight through, and a
444+
* media element handed that blob cannot determine the format and renders nothing.
445+
*
446+
* Returns `null` only when neither the stored type nor the extension identifies the file.
447+
*/
448+
export function resolveEffectiveMimeType(
449+
storedType: string | null | undefined,
450+
filename: string
451+
): string | null {
452+
const stored = storedType?.trim()
453+
if (stored && !GENERIC_MIME_TYPES.has(stored)) return stored
454+
455+
const fromExtension = getMimeTypeFromExtension(getFileExtension(filename))
456+
if (!GENERIC_MIME_TYPES.has(fromExtension)) return fromExtension
457+
458+
return stored || null
459+
}
460+
419461
/**
420462
* Whether `error` is a DOM `AbortError` (XHR `abort()`, fetch `signal.aborted`,
421463
* etc). Used in upload retry loops so aborts short-circuit instead of retrying.

0 commit comments

Comments
 (0)