Skip to content
Open
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: 13 additions & 0 deletions apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/workspaces/[id]/files/inline/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -43,7 +47,13 @@ afterEach(() => {
})

function render(record: WorkspaceFileRecord = file) {
act(() => root.render(<ImagePreview file={record} />))
act(() =>
root.render(
<FileContentSourceProvider value={createWorkspaceFileContentSource(record.workspaceId)}>
<ImagePreview file={record} />
</FileContentSourceProvider>
)
)
}

describe('ImagePreview', () => {
Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
31 changes: 26 additions & 5 deletions apps/sim/hooks/use-file-content-source.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -81,7 +85,7 @@ function buildServeUrl(key: string, opts?: FileContentUrlOptions): string {
function inlineImageSource(
buildUrl: FileContentSource['buildUrl'],
inlineBase: string
): FileContentSource {
): Pick<FileContentSource, 'buildUrl' | 'resolveImageSrc'> {
return {
buildUrl,
resolveImageSrc: (src) => {
Expand All @@ -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,
}
}
Expand All @@ -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`
)
}
}

/**
Expand All @@ -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,
}

Expand Down
Loading