diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 87282d86f4d..bd877ef5508 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -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' @@ -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 }) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index a5f97e4b431..33b9a7740ba 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -60,6 +60,21 @@ export const contentTypeMap: Record = { 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', } @@ -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', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts index 5cfd8498edc..cd3ff2b3b6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts @@ -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 ', + (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') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts index f405f181609..22fa9703ec9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts @@ -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 ``, 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', 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 ce72038ac74..9b0b6af14dd 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 @@ -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, @@ -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 @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 6733fcd84e7..92dca7e8221 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -36,6 +36,7 @@ import { getMimeTypeFromExtension, isAudioFileType, isVideoFileType, + resolveEffectiveMimeType, } from '@/lib/uploads/utils/file-utils' import { isSupportedExtension, @@ -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() { @@ -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 }) } diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index c771d7e29fe..79032282bcf 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -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' @@ -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') + }) +}) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 9e331c10854..da2c51c0f91 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -380,30 +380,106 @@ const EXTENSION_TO_MIME: Record = { mkv: 'video/x-matroska', } +const GENERIC_MIME_TYPE = 'application/octet-stream' + +/** + * Containers that hold either audio or video, mapped to the kind this app presents them as. + * A filename cannot say which a `.webm` is, and the viewer already routes it to the video + * player, so everything user-facing has to agree — otherwise one file reads "Audio" in the + * Type column and opens in a `