Skip to content

Commit ff3b422

Browse files
authored
feat(files): preview HEIC photos in the file viewer (#6350)
* feat(files): preview HEIC photos in the file viewer The agent can read HEIC since #6346, but the Files page still showed 'Preview not available' — an <img> pointed at the serve route got the stored HEIF under nosniff, which no browser outside Safari renders. The serve route now resolves a JPEG derivative for HEIF bytes, cached in the artifact store and keyed by the source's storage key. Workspace keys are regenerated on every content replacement, so the key is already a content version and using it avoids streaming the original just to hash it. Caching matters here in a way it did not for the vision path: a preview is re-fetched on every view and the WASM decode costs roughly a second for a phone photo. The original stays the stored object — downloads and raw=1 serve it untouched, so this never changes what a user gets back. compileDocumentIfNeeded becomes resolveServableBytes, since it now resolves images as well as generated documents. .tif/.tiff stay download-only: nothing decodes those on either side. * fix(files): make the preview derivative opt-in and never show a broken image Five issues from review, all interlocking around one decision. The derivative is now requested with preview=1 rather than suppressed with raw=1. raw=1 would have corrupted generated-document downloads: every non-markdown workspace download routes through the serve route and relies on resolveServableDocBytes compiling stored source into the real binary. Opt-in separates the three consumers cleanly — previews get the JPEG, downloads get untouched stored bytes, and doc compilation stays unconditional. - Public shares resolve the derivative too, with the same preview/download split; the viewer requests it, the download button does not. - Split the brand predicate. isHeifContainer stays broad for the vision path, where it only runs after sharp has already failed. The serve path runs first, so it uses isHevcHeifContainer — an AVIF was costing a storage round-trip, a WASM load and a misleading warn per request. - A derivative that cannot be produced (past the 20MB ceiling, or a decode failure) now falls back to 'Preview not available' instead of a broken image. UnsupportedPreview moved to preview-shared to avoid a module cycle. - The chat composer chip requests the derivative, so HEIC attachments stop rendering as broken thumbnails. * fix(files): reset the image preview when the file is overwritten An overwrite preserves the storage key, which is what the parent keys this component on, so only the URL version changes and it never remounts. The previous bytes' outcome therefore stuck, leaving a replaced image parked on 'Preview not available' until something else forced a remount. Reset on URL change during render rather than in an effect — this is derived state, and an effect would render the stale outcome first. * improvement(files): drop the dead preview reset and cap the ftyp brand scan - Content writes mint a new storage key, so the parent's key={file.key} already remounts ImagePreview; the render-phase reset was unreachable and made renames flash a loading overlay. - Clamp the ftyp compatible-brand scan to a real box size. The declared size is attacker-controlled and this now runs on every preview request. - UnsupportedPreview takes a primitive name so memo is load-bearing. - Fix the hardcoded ? in the public preview URL builder. * improvement(copilot): only ask for a preview derivative on image thumbnails A video has no derivative path, so preview=1 there only spent a brand sniff per request. Adds the missing test coverage for the helper.
1 parent a512a26 commit ff3b422

17 files changed

Lines changed: 547 additions & 103 deletions

File tree

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ 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 { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
1415
import {
1516
createErrorResponse,
1617
createFileResponse,
@@ -31,8 +32,10 @@ const logger = createLogger('PublicFileContentAPI')
3132
*
3233
* Generated office docs are stored as source; {@link resolveServableDoc} swaps in
3334
* their prebuilt compiled binary (read-only, never compiles). Uploaded binaries
34-
* pass through untouched. A generated doc whose compiled artifact isn't built yet
35-
* returns 409 rather than serving raw source under a binary content type.
35+
* pass through untouched, except under `preview=1`, where a format no browser
36+
* decodes is substituted with a renderable derivative. A generated doc whose
37+
* compiled artifact isn't built yet returns 409 rather than serving raw source
38+
* under a binary content type.
3639
*/
3740
export const GET = withRouteHandler(
3841
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
@@ -45,6 +48,7 @@ export const GET = withRouteHandler(
4548
const parsed = await parseRequest(getPublicFileContentContract, request, context)
4649
if (!parsed.success) return parsed.response
4750
const { token } = parsed.data.params
51+
const preview = parsed.data.query.preview === '1'
4852

4953
const resolved = await resolveActiveShareByToken(token)
5054
if (!resolved) {
@@ -77,12 +81,20 @@ export const GET = withRouteHandler(
7781
)
7882
}
7983

80-
const buffer = servable.kind === 'artifact' ? servable.buffer : raw
8184
// This response is `nosniff`, so a stored `application/octet-stream` refuses to render
8285
// even though the bytes are fine. Resolving from the filename also keeps this route on
8386
// the same inline allowlist as the workspace serve route.
84-
const contentType =
85-
servable.kind === 'artifact' ? servable.contentType : getContentType(file.originalName)
87+
let buffer = raw
88+
let contentType = getContentType(file.originalName)
89+
if (servable.kind === 'artifact') {
90+
buffer = servable.buffer
91+
contentType = servable.contentType
92+
} else if (preview) {
93+
// Only for a render request: the Download button omits `preview`, so a saved
94+
// file is always the bytes that were shared.
95+
const image = await resolveServableImageBytes(raw, file.key)
96+
if (image) ({ buffer, contentType } = image)
97+
}
8698

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

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 62 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
1313
import type { StorageContext } from '@/lib/uploads/config'
1414
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1515
import { downloadFile } from '@/lib/uploads/core/storage-service'
16+
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
1617
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
1718
import { verifyFileAccess } from '@/app/api/files/authorization'
1819
import {
@@ -25,20 +26,43 @@ import {
2526

2627
const logger = createLogger('FilesServeAPI')
2728

29+
interface ServeOptions {
30+
/** `raw=1` — bypass all resolution and serve the stored source as-is. */
31+
raw: boolean
32+
/** `preview=1` — the caller renders these bytes rather than saving them. */
33+
preview: boolean
34+
/** `v=<updatedAt>` — the URL addresses content-immutable bytes. */
35+
versioned: boolean
36+
}
37+
2838
/**
29-
* Resolves the bytes + content type to serve for a stored file via the shared
30-
* {@link resolveServableDocBytes} (generated docs → compiled artifact). `raw=1`
31-
* bypasses resolution and serves the stored source as-is.
39+
* Resolves the bytes + content type to serve for a stored file.
40+
*
41+
* Document compilation is unconditional: a generated `.docx`/`.xlsx`/`.pptx` is
42+
* stored as source, so the compiled artifact *is* the file, and every download
43+
* routes through here. An image derivative is the opposite — the stored bytes are
44+
* the file — so it is served only when the caller asked to preview, never when it
45+
* asked to download.
3246
*/
33-
async function compileDocumentIfNeeded(
34-
buffer: Buffer,
35-
filename: string,
36-
workspaceId: string | undefined,
37-
raw: boolean,
38-
ownerKey: string | undefined,
47+
async function resolveServableBytes(params: {
48+
buffer: Buffer
49+
filename: string
50+
storageKey: string
51+
workspaceId: string | undefined
52+
options: ServeOptions
53+
ownerKey: string | undefined
3954
signal: AbortSignal | undefined
40-
): Promise<{ buffer: Buffer; contentType: string }> {
41-
if (raw) return { buffer, contentType: getContentType(filename) }
55+
}): Promise<{ buffer: Buffer; contentType: string }> {
56+
const { buffer, filename, storageKey, workspaceId, options, ownerKey, signal } = params
57+
if (options.raw) return { buffer, contentType: getContentType(filename) }
58+
59+
if (options.preview) {
60+
// Images resolve independently of the document path: a HEIF has no compiled-source
61+
// concept, so it never reaches the doc branch.
62+
const image = await resolveServableImageBytes(buffer, storageKey)
63+
if (image) return image
64+
}
65+
4266
return resolveServableDocBytes({
4367
rawBuffer: buffer,
4468
fileName: filename,
@@ -117,10 +141,14 @@ export const GET = withRouteHandler(
117141

118142
const query = fileServeQuerySchema.parse({
119143
raw: request.nextUrl.searchParams.get('raw'),
144+
preview: request.nextUrl.searchParams.get('preview'),
120145
v: request.nextUrl.searchParams.get('v'),
121146
})
122-
const raw = query.raw === '1'
123-
const versioned = query.v != null
147+
const options: ServeOptions = {
148+
raw: query.raw === '1',
149+
preview: query.preview === '1',
150+
versioned: query.v != null,
151+
}
124152

125153
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
126154

@@ -135,10 +163,10 @@ export const GET = withRouteHandler(
135163
const userId = authResult.userId
136164

137165
if (isUsingCloudStorage()) {
138-
return await handleCloudProxy(cloudKey, userId, raw, versioned, request.signal)
166+
return await handleCloudProxy(cloudKey, userId, options, request.signal)
139167
}
140168

141-
return await handleLocalFile(cloudKey, userId, raw, versioned, request.signal)
169+
return await handleLocalFile(cloudKey, userId, options, request.signal)
142170
} catch (error) {
143171
// An in-progress/incomplete doc source fails to compile — this is expected
144172
// mid-generation, not a server fault. Return 409 (not 500) so it isn't an
@@ -165,8 +193,7 @@ export const GET = withRouteHandler(
165193
async function handleLocalFile(
166194
filename: string,
167195
userId: string,
168-
raw: boolean,
169-
versioned: boolean,
196+
options: ServeOptions,
170197
signal: AbortSignal | undefined
171198
): Promise<NextResponse> {
172199
const ownerKey = `user:${userId}`
@@ -198,22 +225,23 @@ async function handleLocalFile(
198225
const segment = filename.split('/').pop() || filename
199226
const displayName = stripStorageKeyPrefix(segment)
200227
const workspaceId = getWorkspaceIdForCompile(filename)
201-
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
202-
rawBuffer,
203-
displayName,
228+
const { buffer: fileBuffer, contentType } = await resolveServableBytes({
229+
buffer: rawBuffer,
230+
filename: displayName,
231+
storageKey: filename,
204232
workspaceId,
205-
raw,
233+
options,
206234
ownerKey,
207-
signal
208-
)
235+
signal,
236+
})
209237

210238
logger.info('Local file served', { userId, filename, size: fileBuffer.length })
211239

212240
return createFileResponse({
213241
buffer: fileBuffer,
214242
contentType,
215243
filename: displayName,
216-
cacheControl: resolveServeCacheControl(versioned, contextParam),
244+
cacheControl: resolveServeCacheControl(options.versioned, contextParam),
217245
})
218246
} catch (error) {
219247
logger.error('Error reading local file:', error)
@@ -224,9 +252,8 @@ async function handleLocalFile(
224252
async function handleCloudProxy(
225253
cloudKey: string,
226254
userId: string,
227-
raw = false,
228-
versioned = false,
229-
signal: AbortSignal | undefined = undefined
255+
options: ServeOptions,
256+
signal: AbortSignal | undefined
230257
): Promise<NextResponse> {
231258
const ownerKey = `user:${userId}`
232259
try {
@@ -260,14 +287,15 @@ async function handleCloudProxy(
260287
const segment = cloudKey.split('/').pop() || 'download'
261288
const displayName = stripStorageKeyPrefix(segment)
262289
const workspaceId = getWorkspaceIdForCompile(cloudKey)
263-
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
264-
rawBuffer,
265-
displayName,
290+
const { buffer: fileBuffer, contentType } = await resolveServableBytes({
291+
buffer: rawBuffer,
292+
filename: displayName,
293+
storageKey: cloudKey,
266294
workspaceId,
267-
raw,
295+
options,
268296
ownerKey,
269-
signal
270-
)
297+
signal,
298+
})
271299

272300
logger.info('Cloud file served', {
273301
userId,
@@ -280,7 +308,7 @@ async function handleCloudProxy(
280308
buffer: fileBuffer,
281309
contentType,
282310
filename: displayName,
283-
cacheControl: resolveServeCacheControl(versioned, context),
311+
cacheControl: resolveServeCacheControl(options.versioned, context),
284312
})
285313
} catch (error) {
286314
logger.error('Error downloading from cloud storage:', error)

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,15 @@ describe('resolveFileCategory — formats accepted on upload must be previewable
227227
['image.bmp', 'image/bmp'],
228228
['image.avif', 'image/avif'],
229229
['favicon.ico', 'image/x-icon'],
230+
['photo.heic', 'image/heic'],
231+
['photo.heif', 'image/heif'],
230232
])('%s previews as an image', (filename, mimeType) => {
231233
expect(resolveFileCategory(mimeType, filename)).toBe('image-previewable')
232234
expect(resolveFileCategory('application/octet-stream', filename)).toBe('image-previewable')
233235
})
234236

235-
it.each(['image.tiff', 'photo.heic'])(
236-
'%s stays unsupported — no browser renders it in an <img>',
237+
it.each(['image.tiff', 'scan.tif'])(
238+
'%s stays unsupported — nothing decodes it on either side',
237239
(filename) => {
238240
expect(resolveFileCategory(null, filename)).toBe('unsupported')
239241
}

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
4444
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4545

4646
/**
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.
47+
* Formats the image viewer can show. Most are decoded by the browser directly;
48+
* `.heic`/`.heif` are not — no browser outside Safari renders them — but the serve
49+
* route transcodes them to JPEG, so an `<img>` pointed at it works.
50+
*
51+
* `.tif`/`.tiff` are accepted uploads and deliberately absent: nothing decodes them
52+
* on either side, so they stay download-only rather than showing a broken image.
5153
*/
5254
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
5355
'image/png',
@@ -58,6 +60,8 @@ const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
5860
'image/bmp',
5961
'image/x-icon',
6062
'image/vnd.microsoft.icon',
63+
'image/heic',
64+
'image/heif',
6165
])
6266
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
6367
'png',
@@ -68,6 +72,8 @@ const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
6872
'avif',
6973
'bmp',
7074
'ico',
75+
'heic',
76+
'heif',
7177
])
7278

7379
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([

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

Lines changed: 4 additions & 22 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, resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
7+
import { resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
88
import {
99
useWorkspaceFileBinary,
1010
useWorkspaceFileContent,
@@ -28,6 +28,7 @@ import {
2828
PreviewErrorBoundary,
2929
PreviewLoadingFrame,
3030
resolvePreviewError,
31+
UnsupportedPreview,
3132
} from './preview-shared'
3233
import { TextEditor } from './text-editor'
3334
import { useDocPreviewBinary } from './use-doc-preview-binary'
@@ -170,7 +171,7 @@ function FileViewerContent({
170171
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
171172
// read-only public path a large CSV is download-only.
172173
if (isCsvStreamOnly(file)) {
173-
return <UnsupportedPreview file={file} />
174+
return <UnsupportedPreview name={file.name} />
174175
}
175176
// Markdown renders through the inline rich editor (non-editable) so the public share
176177
// surface matches the in-app reading experience; canEdit={false} disables autosave,
@@ -259,7 +260,7 @@ function FileViewerContent({
259260
return <XlsxPreview key={file.id} file={file} workspaceId={workspaceId} />
260261
}
261262

262-
return <UnsupportedPreview file={file} />
263+
return <UnsupportedPreview name={file.name} />
263264
}
264265

265266
/**
@@ -421,22 +422,3 @@ const MediaPreview = memo(function MediaPreview({
421422
</div>
422423
)
423424
})
424-
425-
const UnsupportedPreview = memo(function UnsupportedPreview({
426-
file,
427-
}: {
428-
file: WorkspaceFileRecord
429-
}) {
430-
const ext = getFileExtension(file.name)
431-
432-
return (
433-
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
434-
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
435-
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
436-
</p>
437-
<p className='text-[13px] text-[var(--text-muted)]'>
438-
Use the download button to view this file
439-
</p>
440-
</div>
441-
)
442-
})

0 commit comments

Comments
 (0)