Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/sim/app/api/files/public/[token]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
import {
createErrorResponse,
createFileResponse,
FileNotFoundError,
getContentType,
} from '@/app/api/files/utils'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -73,7 +78,11 @@ export const GET = withRouteHandler(
}

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

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

Expand Down
18 changes: 18 additions & 0 deletions apps/sim/app/api/files/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ export const contentTypeMap: Record<string, string> = {
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
opus: 'audio/opus',
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
webm: 'video/webm',
zip: 'application/zip',
googleFolder: 'application/vnd.google-apps.folder',
}
Expand Down Expand Up @@ -159,6 +174,9 @@ const SAFE_INLINE_TYPES = new Set([
'image/gif',
'image/svg+xml',
'image/webp',
'image/avif',
'image/bmp',
'image/x-icon',
'application/pdf',
'text/plain',
'text/csv',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,24 @@ describe('resolveFileCategory — MIME priority', () => {
})
})

describe('resolveFileCategory — formats accepted on upload must be previewable', () => {
it.each([
['image.bmp', 'image/bmp'],
['image.avif', 'image/avif'],
['favicon.ico', 'image/x-icon'],
])('%s previews as an image', (filename, mimeType) => {
expect(resolveFileCategory(mimeType, filename)).toBe('image-previewable')
expect(resolveFileCategory('application/octet-stream', filename)).toBe('image-previewable')
})

it.each(['image.tiff', 'photo.heic'])(
'%s stays unsupported — no browser renders it in an <img>',
(filename) => {
expect(resolveFileCategory(null, filename)).toBe('unsupported')
}
)
})

describe('resolveFileCategory — extension case', () => {
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,32 @@ const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
])
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])

const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
/**
* Image formats every supported browser decodes natively. `.tif`/`.tiff` and
* `.heic`/`.heif` are accepted uploads but deliberately absent — no browser renders
* them in an `<img>`, so they stay on the download-only path rather than showing a
* broken image.
*/
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/x-icon',
'image/vnd.microsoft.icon',
])
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'avif',
'bmp',
'ico',
])

const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
'audio/mpeg',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Music } from '@sim/emcn/icons'
import dynamic from 'next/dynamic'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { getFileExtension, resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
import {
useWorkspaceFileBinary,
useWorkspaceFileContent,
Expand Down Expand Up @@ -361,8 +361,6 @@ function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
}

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

/**
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
* lifecycle, and error/loading handling are identical; only the rendered
Expand All @@ -385,12 +383,12 @@ const MediaPreview = memo(function MediaPreview({
replaceBlobUrl,
} = useBlobUrl(workspaceId, file.id, file.key)

const mediaType = resolveMediaMimeType(file.type, file.name, kind)

useEffect(() => {
if (!fileData) return
replaceBlobUrl(
URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] }))
)
}, [file.type, fileData, kind, replaceBlobUrl])
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: mediaType })))
}, [fileData, mediaType, replaceBlobUrl])

const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
if (error) return <PreviewError label={kind} error={error} />
Expand Down
24 changes: 15 additions & 9 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
getMimeTypeFromExtension,
isAudioFileType,
isVideoFileType,
resolveEffectiveMimeType,
} from '@/lib/uploads/utils/file-utils'
import {
isSupportedExtension,
Expand Down Expand Up @@ -195,19 +196,21 @@ const parseRowId = (rowId: string): { kind: 'file' | 'folder'; id: string } => {
const hasExternalFiles = (dataTransfer: DataTransfer): boolean =>
dataTransfer.types.includes('Files')

function formatFileType(mimeType: string | null, filename: string): string {
if (mimeType && MIME_TYPE_LABELS[mimeType]) {
function formatFileType(storedType: string | null, filename: string): string {
const mimeType = resolveEffectiveMimeType(storedType, filename)

if (MIME_TYPE_LABELS[mimeType]) {
return MIME_TYPE_LABELS[mimeType]
}

if (mimeType?.startsWith('audio/')) return 'Audio'
if (mimeType?.startsWith('video/')) return 'Video'
if (mimeType?.startsWith('image/')) return 'Image'
if (mimeType.startsWith('audio/')) return 'Audio'
if (mimeType.startsWith('video/')) return 'Video'
if (mimeType.startsWith('image/')) return 'Image'

const ext = getFileExtension(filename)
if (ext) return ext.toUpperCase()

return mimeType ?? 'File'
return storedType ?? 'File'
}

export function Files() {
Expand Down Expand Up @@ -493,10 +496,13 @@ export function Files() {
if (typeFilter.length > 0) {
result = result.filter((f) => {
const ext = getFileExtension(f.name)
// Matching the raw stored type would hide every file the browser uploaded as
// `application/octet-stream` from the audio/video/image filters.
const type = resolveEffectiveMimeType(f.type, f.name)
if (typeFilter.includes('document') && isSupportedExtension(ext)) return true
if (typeFilter.includes('audio') && isAudioFileType(f.type)) return true
if (typeFilter.includes('video') && isVideoFileType(f.type)) return true
if (typeFilter.includes('image') && f.type?.startsWith('image/')) return true
if (typeFilter.includes('audio') && isAudioFileType(type)) return true
if (typeFilter.includes('video') && isVideoFileType(type)) return true
if (typeFilter.includes('image') && type.startsWith('image/')) return true
return false
})
}
Expand Down
87 changes: 87 additions & 0 deletions apps/sim/lib/uploads/utils/file-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { createLogger } from '@sim/logger'
import { describe, expect, it } from 'vitest'
import {
extractStorageKey,
getMimeTypeFromExtension,
inferContextFromKey,
isAbortError,
isInternalFileUrl,
isMarkdownFile,
isNetworkError,
processSingleFileToUserFile,
resolveEffectiveMimeType,
resolveFileType,
resolveMediaMimeType,
resolveTrustedFileContext,
} from '@/lib/uploads/utils/file-utils'

Expand Down Expand Up @@ -193,3 +197,86 @@ describe('processSingleFileToUserFile', () => {
expect(result.key).toBe('workspace/ws-1/doc.pdf')
})
})

describe('resolveEffectiveMimeType', () => {
it('keeps a specific stored type', () => {
expect(resolveEffectiveMimeType('video/quicktime', 'clip.mp4')).toBe('video/quicktime')
expect(resolveEffectiveMimeType('text/markdown', 'notes.md')).toBe('text/markdown')
})

it.each([
['clip.mp4', 'video/mp4'],
['clip.mov', 'video/quicktime'],
['clip.mkv', 'video/x-matroska'],
['song.mp3', 'audio/mpeg'],
['song.flac', 'audio/flac'],
['icon.ico', 'image/x-icon'],
['shot.avif', 'image/avif'],
])('resolves a stored application/octet-stream for %s from the extension', (name, expected) => {
expect(resolveEffectiveMimeType('application/octet-stream', name)).toBe(expected)
})

it('resolves binary/octet-stream and blank stored types too', () => {
expect(resolveEffectiveMimeType('binary/octet-stream', 'clip.mp4')).toBe('video/mp4')
expect(resolveEffectiveMimeType(' ', 'clip.mp4')).toBe('video/mp4')
expect(resolveEffectiveMimeType(null, 'clip.mp4')).toBe('video/mp4')
expect(resolveEffectiveMimeType(undefined, 'clip.mp4')).toBe('video/mp4')
})

it('resolves a dual audio/video container to video, matching how the app presents it', () => {
expect(resolveEffectiveMimeType('application/octet-stream', 'clip.webm')).toBe('video/webm')
expect(resolveEffectiveMimeType(null, 'clip.webm')).toBe('video/webm')
})

it('still keeps an explicit audio/webm declared by the browser', () => {
expect(resolveEffectiveMimeType('audio/webm', 'recording.webm')).toBe('audio/webm')
})

it('leaves the upload-time extension table alone for dual containers', () => {
expect(getMimeTypeFromExtension('webm')).toBe('audio/webm')
})

it('never lets the video default reach the type that gets persisted', () => {
// resolveFileType writes user_file.content_type, which the speech-to-text route reads
// back as file.type — a video/* value there sends the upload into ffmpeg extraction.
expect(resolveFileType({ type: '', name: 'clip.webm' })).toBe('audio/webm')
expect(resolveFileType({ type: 'application/octet-stream', name: 'clip.webm' })).toBe(
'audio/webm'
)
expect(resolveFileType({ type: 'audio/webm', name: 'clip.webm' })).toBe('audio/webm')
})

it('stays generic when the extension identifies nothing either', () => {
expect(resolveEffectiveMimeType('application/octet-stream', 'firmware.bin')).toBe(
'application/octet-stream'
)
expect(resolveEffectiveMimeType(null, 'firmware.bin')).toBe('application/octet-stream')
expect(resolveEffectiveMimeType('', 'noextension')).toBe('application/octet-stream')
})
})

describe('resolveMediaMimeType', () => {
it('resolves a generic stored type from the extension', () => {
expect(resolveMediaMimeType('application/octet-stream', 'clip.mp4', 'video')).toBe('video/mp4')
expect(resolveMediaMimeType('application/octet-stream', 'song.flac', 'audio')).toBe(
'audio/flac'
)
})

it('retags a dual audio/video container to the kind being rendered', () => {
expect(resolveMediaMimeType(null, 'clip.webm', 'video')).toBe('video/webm')
expect(resolveMediaMimeType('audio/webm', 'clip.webm', 'video')).toBe('video/webm')
expect(resolveMediaMimeType(null, 'recording.webm', 'audio')).toBe('audio/webm')
expect(resolveMediaMimeType('video/webm', 'recording.webm', 'audio')).toBe('audio/webm')
})

it('keeps a specific type that already names the right kind', () => {
expect(resolveMediaMimeType('video/quicktime', 'clip.mov', 'video')).toBe('video/quicktime')
expect(resolveMediaMimeType('audio/opus', 'voice.opus', 'audio')).toBe('audio/opus')
})

it('falls back to the kind default when nothing names a media format', () => {
expect(resolveMediaMimeType('application/zip', 'weird.bin', 'audio')).toBe('audio/mpeg')
expect(resolveMediaMimeType(null, 'weird.bin', 'video')).toBe('video/mp4')
})
})
Loading
Loading