Skip to content

Commit a4973ec

Browse files
authored
fix(files): render audio and video stored as application/octet-stream (#6341)
* 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 * fix(files): settle the audio/video container ambiguity at the call site Follow-up to the review pass on this branch. - Revert the global .webm -> video/webm remap. EXTENSION_TO_MIME is shared with non-viewer callers, and a .webm with an empty stored type would have started taking the STT route's video branch (stt/route.ts:211 -> extractAudioFromVideo), which 500s where no ffmpeg binary is on PATH. The ambiguity is now settled in resolveMediaMimeType, which knows which element the caller is rendering - Resolve the public share route's Content-Type from the filename via getContentType, matching the workspace serve route, instead of echoing the client-declared stored type into a public unauthenticated response. Add the audio/video entries contentTypeMap was missing so a shared media file keeps a real Content-Type (disposition is unchanged - none are inline-safe) - Make resolveEffectiveMimeType total (string, not string | null); the null contract only bought one label edge case and cost a ?? at every call site, one of which was dead - Drop .jsonl from the text-editable set. The editor loads the whole file and only CSV has a byte cap, so a large .jsonl would trade a download-only fallback for a crashed tab. Needs the size guard generalized first - Trim two comments that restated their code * fix(files): resolve dual audio/video containers to the kind the app presents The viewer routes .webm to the video player, but the Type column and the audio/video filters resolved it through EXTENSION_TO_MIME and read audio/webm, so one file showed as Audio and opened in a <video>. resolveEffectiveMimeType now consults a DUAL_CONTAINER_MIME map first. It stays out of EXTENSION_TO_MIME because the speech-to-text and ElevenLabs routes read that table directly, where a video/* label pushes a .webm into ffmpeg audio extraction it does not need. * fix(files): keep the dual-container video default out of the persisted type resolveFileType writes user_file.content_type, and it delegated to resolveEffectiveMimeType, so DUAL_CONTAINER_MIME could persist video/webm. The speech-to-text route reads that back as file.type, which sends the upload into the ffmpeg extraction path the previous commit set out to avoid. resolveFileType now resolves through EXTENSION_TO_MIME alone; the video default stays on the presentation path. Both share an identifiesFormat predicate.
1 parent 8c49d35 commit a4973ec

8 files changed

Lines changed: 265 additions & 29 deletions

File tree

apps/sim/app/api/files/public/[token]/content/route.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
1212
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
1313
import { downloadFile } from '@/lib/uploads/core/storage-service'
14-
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
14+
import {
15+
createErrorResponse,
16+
createFileResponse,
17+
FileNotFoundError,
18+
getContentType,
19+
} from '@/app/api/files/utils'
1520

1621
export const dynamic = 'force-dynamic'
1722

@@ -73,7 +78,11 @@ export const GET = withRouteHandler(
7378
}
7479

7580
const buffer = servable.kind === 'artifact' ? servable.buffer : raw
76-
const contentType = servable.kind === 'artifact' ? servable.contentType : file.contentType
81+
// This response is `nosniff`, so a stored `application/octet-stream` refuses to render
82+
// even though the bytes are fine. Resolving from the filename also keeps this route on
83+
// the same inline allowlist as the workspace serve route.
84+
const contentType =
85+
servable.kind === 'artifact' ? servable.contentType : getContentType(file.originalName)
7786

7887
logger.info('Public shared file served', { token, key: file.key, size: buffer.length })
7988

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ 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',
66+
mp3: 'audio/mpeg',
67+
m4a: 'audio/mp4',
68+
wav: 'audio/wav',
69+
ogg: 'audio/ogg',
70+
flac: 'audio/flac',
71+
aac: 'audio/aac',
72+
opus: 'audio/opus',
73+
mp4: 'video/mp4',
74+
mov: 'video/quicktime',
75+
avi: 'video/x-msvideo',
76+
mkv: 'video/x-matroska',
77+
webm: 'video/webm',
6378
zip: 'application/zip',
6479
googleFolder: 'application/vnd.google-apps.folder',
6580
}
@@ -159,6 +174,9 @@ const SAFE_INLINE_TYPES = new Set([
159174
'image/gif',
160175
'image/svg+xml',
161176
'image/webp',
177+
'image/avif',
178+
'image/bmp',
179+
'image/x-icon',
162180
'application/pdf',
163181
'text/plain',
164182
'text/csv',

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,24 @@ 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+
225243
describe('resolveFileCategory — extension case', () => {
226244
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
227245
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,32 @@ const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
4343
])
4444
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4545

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

4973
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
5074
'audio/mpeg',

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

Lines changed: 5 additions & 7 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, resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
88
import {
99
useWorkspaceFileBinary,
1010
useWorkspaceFileContent,
@@ -361,8 +361,6 @@ function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
361361
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
362362
}
363363

364-
const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const
365-
366364
/**
367365
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
368366
* lifecycle, and error/loading handling are identical; only the rendered
@@ -385,12 +383,12 @@ const MediaPreview = memo(function MediaPreview({
385383
replaceBlobUrl,
386384
} = useBlobUrl(workspaceId, file.id, file.key)
387385

386+
const mediaType = resolveMediaMimeType(file.type, file.name, kind)
387+
388388
useEffect(() => {
389389
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])
390+
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: mediaType })))
391+
}, [fileData, mediaType, replaceBlobUrl])
394392

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

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

Lines changed: 15 additions & 9 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,19 +196,21 @@ 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-
if (mimeType && MIME_TYPE_LABELS[mimeType]) {
199+
function formatFileType(storedType: string | null, filename: string): string {
200+
const mimeType = resolveEffectiveMimeType(storedType, filename)
201+
202+
if (MIME_TYPE_LABELS[mimeType]) {
200203
return MIME_TYPE_LABELS[mimeType]
201204
}
202205

203-
if (mimeType?.startsWith('audio/')) return 'Audio'
204-
if (mimeType?.startsWith('video/')) return 'Video'
205-
if (mimeType?.startsWith('image/')) return 'Image'
206+
if (mimeType.startsWith('audio/')) return 'Audio'
207+
if (mimeType.startsWith('video/')) return 'Video'
208+
if (mimeType.startsWith('image/')) return 'Image'
206209

207210
const ext = getFileExtension(filename)
208211
if (ext) return ext.toUpperCase()
209212

210-
return mimeType ?? 'File'
213+
return storedType ?? 'File'
211214
}
212215

213216
export function Files() {
@@ -493,10 +496,13 @@ export function Files() {
493496
if (typeFilter.length > 0) {
494497
result = result.filter((f) => {
495498
const ext = getFileExtension(f.name)
499+
// Matching the raw stored type would hide every file the browser uploaded as
500+
// `application/octet-stream` from the audio/video/image filters.
501+
const type = resolveEffectiveMimeType(f.type, f.name)
496502
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
503+
if (typeFilter.includes('audio') && isAudioFileType(type)) return true
504+
if (typeFilter.includes('video') && isVideoFileType(type)) return true
505+
if (typeFilter.includes('image') && type.startsWith('image/')) return true
500506
return false
501507
})
502508
}

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import { createLogger } from '@sim/logger'
55
import { describe, expect, it } from 'vitest'
66
import {
77
extractStorageKey,
8+
getMimeTypeFromExtension,
89
inferContextFromKey,
910
isAbortError,
1011
isInternalFileUrl,
1112
isMarkdownFile,
1213
isNetworkError,
1314
processSingleFileToUserFile,
15+
resolveEffectiveMimeType,
16+
resolveFileType,
17+
resolveMediaMimeType,
1418
resolveTrustedFileContext,
1519
} from '@/lib/uploads/utils/file-utils'
1620

@@ -193,3 +197,86 @@ describe('processSingleFileToUserFile', () => {
193197
expect(result.key).toBe('workspace/ws-1/doc.pdf')
194198
})
195199
})
200+
201+
describe('resolveEffectiveMimeType', () => {
202+
it('keeps a specific stored type', () => {
203+
expect(resolveEffectiveMimeType('video/quicktime', 'clip.mp4')).toBe('video/quicktime')
204+
expect(resolveEffectiveMimeType('text/markdown', 'notes.md')).toBe('text/markdown')
205+
})
206+
207+
it.each([
208+
['clip.mp4', 'video/mp4'],
209+
['clip.mov', 'video/quicktime'],
210+
['clip.mkv', 'video/x-matroska'],
211+
['song.mp3', 'audio/mpeg'],
212+
['song.flac', 'audio/flac'],
213+
['icon.ico', 'image/x-icon'],
214+
['shot.avif', 'image/avif'],
215+
])('resolves a stored application/octet-stream for %s from the extension', (name, expected) => {
216+
expect(resolveEffectiveMimeType('application/octet-stream', name)).toBe(expected)
217+
})
218+
219+
it('resolves binary/octet-stream and blank stored types too', () => {
220+
expect(resolveEffectiveMimeType('binary/octet-stream', 'clip.mp4')).toBe('video/mp4')
221+
expect(resolveEffectiveMimeType(' ', 'clip.mp4')).toBe('video/mp4')
222+
expect(resolveEffectiveMimeType(null, 'clip.mp4')).toBe('video/mp4')
223+
expect(resolveEffectiveMimeType(undefined, 'clip.mp4')).toBe('video/mp4')
224+
})
225+
226+
it('resolves a dual audio/video container to video, matching how the app presents it', () => {
227+
expect(resolveEffectiveMimeType('application/octet-stream', 'clip.webm')).toBe('video/webm')
228+
expect(resolveEffectiveMimeType(null, 'clip.webm')).toBe('video/webm')
229+
})
230+
231+
it('still keeps an explicit audio/webm declared by the browser', () => {
232+
expect(resolveEffectiveMimeType('audio/webm', 'recording.webm')).toBe('audio/webm')
233+
})
234+
235+
it('leaves the upload-time extension table alone for dual containers', () => {
236+
expect(getMimeTypeFromExtension('webm')).toBe('audio/webm')
237+
})
238+
239+
it('never lets the video default reach the type that gets persisted', () => {
240+
// resolveFileType writes user_file.content_type, which the speech-to-text route reads
241+
// back as file.type — a video/* value there sends the upload into ffmpeg extraction.
242+
expect(resolveFileType({ type: '', name: 'clip.webm' })).toBe('audio/webm')
243+
expect(resolveFileType({ type: 'application/octet-stream', name: 'clip.webm' })).toBe(
244+
'audio/webm'
245+
)
246+
expect(resolveFileType({ type: 'audio/webm', name: 'clip.webm' })).toBe('audio/webm')
247+
})
248+
249+
it('stays generic when the extension identifies nothing either', () => {
250+
expect(resolveEffectiveMimeType('application/octet-stream', 'firmware.bin')).toBe(
251+
'application/octet-stream'
252+
)
253+
expect(resolveEffectiveMimeType(null, 'firmware.bin')).toBe('application/octet-stream')
254+
expect(resolveEffectiveMimeType('', 'noextension')).toBe('application/octet-stream')
255+
})
256+
})
257+
258+
describe('resolveMediaMimeType', () => {
259+
it('resolves a generic stored type from the extension', () => {
260+
expect(resolveMediaMimeType('application/octet-stream', 'clip.mp4', 'video')).toBe('video/mp4')
261+
expect(resolveMediaMimeType('application/octet-stream', 'song.flac', 'audio')).toBe(
262+
'audio/flac'
263+
)
264+
})
265+
266+
it('retags a dual audio/video container to the kind being rendered', () => {
267+
expect(resolveMediaMimeType(null, 'clip.webm', 'video')).toBe('video/webm')
268+
expect(resolveMediaMimeType('audio/webm', 'clip.webm', 'video')).toBe('video/webm')
269+
expect(resolveMediaMimeType(null, 'recording.webm', 'audio')).toBe('audio/webm')
270+
expect(resolveMediaMimeType('video/webm', 'recording.webm', 'audio')).toBe('audio/webm')
271+
})
272+
273+
it('keeps a specific type that already names the right kind', () => {
274+
expect(resolveMediaMimeType('video/quicktime', 'clip.mov', 'video')).toBe('video/quicktime')
275+
expect(resolveMediaMimeType('audio/opus', 'voice.opus', 'audio')).toBe('audio/opus')
276+
})
277+
278+
it('falls back to the kind default when nothing names a media format', () => {
279+
expect(resolveMediaMimeType('application/zip', 'weird.bin', 'audio')).toBe('audio/mpeg')
280+
expect(resolveMediaMimeType(null, 'weird.bin', 'video')).toBe('video/mp4')
281+
})
282+
})

0 commit comments

Comments
 (0)