diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts
index 2b95cd7e007..e0d9f4cf41e 100644
--- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts
@@ -60,6 +60,19 @@ describe('GET /api/workspaces/[id]/files/inline', () => {
})
})
+ it('derives a renderable image type when storage recorded generic bytes', async () => {
+ mockReadInline.mockResolvedValue({
+ file: { name: 'photo.png', type: 'application/octet-stream', size: PNG.length },
+ stream: new Blob([new Uint8Array(PNG)]).stream(),
+ contentAddressed: true,
+ })
+
+ const res = await GET(req('key=workspace%2Fws-1%2Fphoto.png'), params)
+
+ expect(res.headers.get('Content-Type')).toBe('image/png')
+ expect(res.headers.get('Content-Disposition')).toBe('inline; filename="photo.png"')
+ })
+
/**
* A storage key names one object and a content write never rewrites one, so these bytes can never
* change. Revalidating them meant re-downloading every embedded image on every open — a document is
diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts
index 0a3f20bf4ec..a00d828f2c2 100644
--- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts
@@ -4,6 +4,7 @@ import {
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
+import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file-utils'
import { internalFileErrorPolicies } from '@/lib/workspace-files/api'
import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file'
import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/utils'
@@ -48,7 +49,7 @@ export const GET = defineInternalBinaryRoute({
}),
useCase: readWorkspaceInlineFile,
present: ({ file, stream, contentAddressed }) => {
- const secure = getSecureFileHeaders(file.name, file.type)
+ const secure = getSecureFileHeaders(file.name, resolveEffectiveMimeType(file.type, file.name))
const headers = new Headers({
'Content-Type': secure.contentType,
'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx
index 17f3885d081..40942cddb17 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx
@@ -5,6 +5,10 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import {
+ createWorkspaceFileContentSource,
+ FileContentSourceProvider,
+} from '@/hooks/use-file-content-source'
import { ImagePreview } from './image-preview'
const file = {
@@ -43,7 +47,13 @@ afterEach(() => {
})
function render(record: WorkspaceFileRecord = file) {
- act(() => root.render())
+ act(() =>
+ root.render(
+
+
+
+ )
+ )
}
describe('ImagePreview', () => {
@@ -55,6 +65,13 @@ describe('ImagePreview', () => {
expect(src).not.toContain('raw=1')
})
+ it('streams browser-renderable images through the workspace inline endpoint', () => {
+ render({ ...file, name: 'photo.png', key: 'workspace/ws-1/photo.png', type: 'image/png' })
+
+ const src = container.querySelector('img')?.getAttribute('src') ?? ''
+ expect(src).toBe('/api/workspaces/ws-1/files/inline?key=workspace%2Fws-1%2Fphoto.png')
+ })
+
it('falls back to the unsupported state when the image fails to decode', () => {
render()
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx
index 4c6659c2975..6f719ba2dbb 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx
@@ -8,9 +8,9 @@ import { ZoomablePreview } from './zoomable-preview'
export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const source = useFileContentSource()
- /** `v` busts the browser cache across content writes; `preview` lets the server
- * substitute a renderable JPEG for a HEIC. */
- const serveUrl = source.buildUrl(file.key, {
+ /** Workspace images use their content-addressed inline URL. Derivative-backed
+ * sources use the version and preview flag to render formats such as HEIC. */
+ const serveUrl = source.buildImageUrl(file, {
version: Number(new Date(file.updatedAt)) || file.size,
preview: true,
})
diff --git a/apps/sim/hooks/use-file-content-source.tsx b/apps/sim/hooks/use-file-content-source.tsx
index bcdd1d3f493..44f421874b0 100644
--- a/apps/sim/hooks/use-file-content-source.tsx
+++ b/apps/sim/hooks/use-file-content-source.tsx
@@ -56,6 +56,10 @@ export interface ImageDimensionsSource {
*/
export interface FileContentSource {
buildUrl: (key: string, opts?: FileContentUrlOptions) => string
+ buildImageUrl: (
+ file: { key: string; name: string; type: string },
+ opts?: FileContentUrlOptions
+ ) => string
/**
* Map an embedded image `src` to a display URL scoped to the current context: the in-app source
* points at the workspace-scoped inline route, the public source at the token-scoped cascade route.
@@ -81,7 +85,7 @@ function buildServeUrl(key: string, opts?: FileContentUrlOptions): string {
function inlineImageSource(
buildUrl: FileContentSource['buildUrl'],
inlineBase: string
-): FileContentSource {
+): Pick {
return {
buildUrl,
resolveImageSrc: (src) => {
@@ -103,6 +107,16 @@ export function createWorkspaceFileContentSource(
): FileContentSource {
return {
...inlineImageSource(buildServeUrl, `/api/workspaces/${workspaceId}/files/inline`),
+ buildImageUrl: (file, opts) => {
+ const heic =
+ file.type === 'image/heic' ||
+ file.type === 'image/heif' ||
+ /\.(?:heic|heif)$/i.test(file.name)
+ if (heic) return buildServeUrl(file.key, { ...opts, preview: true })
+
+ const params = new URLSearchParams({ key: file.key })
+ return `/api/workspaces/${encodeURIComponent(workspaceId)}/files/inline?${params}`
+ },
...imageDimensions,
}
}
@@ -116,11 +130,17 @@ export function createPublicFileContentSource(
token: string,
contentUrl: string
): FileContentSource {
- return inlineImageSource(
- (_key, opts) =>
+ return {
+ ...inlineImageSource(
+ (_key, opts) =>
+ opts?.preview
+ ? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1`
+ : contentUrl,
+ `/api/files/public/${token}/inline`
+ ),
+ buildImageUrl: (_file, opts) =>
opts?.preview ? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1` : contentUrl,
- `/api/files/public/${token}/inline`
- )
+ }
}
/**
@@ -130,6 +150,7 @@ export function createPublicFileContentSource(
*/
export const workspaceFileContentSource: FileContentSource = {
buildUrl: buildServeUrl,
+ buildImageUrl: (file, opts) => buildServeUrl(file.key, { ...opts, preview: true }),
resolveImageSrc: (src) => src,
}