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
22 changes: 17 additions & 5 deletions apps/sim/app/api/files/public/[token]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
import {
createErrorResponse,
createFileResponse,
Expand All @@ -31,8 +32,10 @@ const logger = createLogger('PublicFileContentAPI')
*
* Generated office docs are stored as source; {@link resolveServableDoc} swaps in
* their prebuilt compiled binary (read-only, never compiles). Uploaded binaries
* pass through untouched. A generated doc whose compiled artifact isn't built yet
* returns 409 rather than serving raw source under a binary content type.
* pass through untouched, except under `preview=1`, where a format no browser
* decodes is substituted with a renderable derivative. A generated doc whose
* compiled artifact isn't built yet returns 409 rather than serving raw source
* under a binary content type.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
Expand All @@ -45,6 +48,7 @@ export const GET = withRouteHandler(
const parsed = await parseRequest(getPublicFileContentContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
const preview = parsed.data.query.preview === '1'

const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
Expand Down Expand Up @@ -77,12 +81,20 @@ export const GET = withRouteHandler(
)
}

const buffer = servable.kind === 'artifact' ? servable.buffer : raw
// 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)
let buffer = raw
let contentType = getContentType(file.originalName)
if (servable.kind === 'artifact') {
buffer = servable.buffer
contentType = servable.contentType
} else if (preview) {
// Only for a render request: the Download button omits `preview`, so a saved
// file is always the bytes that were shared.
const image = await resolveServableImageBytes(raw, file.key)
if (image) ({ buffer, contentType } = image)
}

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

Expand Down
96 changes: 62 additions & 34 deletions apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import {
Expand All @@ -25,20 +26,43 @@ import {

const logger = createLogger('FilesServeAPI')

interface ServeOptions {
/** `raw=1` — bypass all resolution and serve the stored source as-is. */
raw: boolean
/** `preview=1` — the caller renders these bytes rather than saving them. */
preview: boolean
/** `v=<updatedAt>` — the URL addresses content-immutable bytes. */
versioned: boolean
}

/**
* Resolves the bytes + content type to serve for a stored file via the shared
* {@link resolveServableDocBytes} (generated docs → compiled artifact). `raw=1`
* bypasses resolution and serves the stored source as-is.
* Resolves the bytes + content type to serve for a stored file.
*
* Document compilation is unconditional: a generated `.docx`/`.xlsx`/`.pptx` is
* stored as source, so the compiled artifact *is* the file, and every download
* routes through here. An image derivative is the opposite — the stored bytes are
* the file — so it is served only when the caller asked to preview, never when it
* asked to download.
*/
async function compileDocumentIfNeeded(
buffer: Buffer,
filename: string,
workspaceId: string | undefined,
raw: boolean,
ownerKey: string | undefined,
async function resolveServableBytes(params: {
buffer: Buffer
filename: string
storageKey: string
workspaceId: string | undefined
options: ServeOptions
ownerKey: string | undefined
signal: AbortSignal | undefined
): Promise<{ buffer: Buffer; contentType: string }> {
if (raw) return { buffer, contentType: getContentType(filename) }
}): Promise<{ buffer: Buffer; contentType: string }> {
const { buffer, filename, storageKey, workspaceId, options, ownerKey, signal } = params
if (options.raw) return { buffer, contentType: getContentType(filename) }

if (options.preview) {
// Images resolve independently of the document path: a HEIF has no compiled-source
// concept, so it never reaches the doc branch.
const image = await resolveServableImageBytes(buffer, storageKey)
if (image) return image
}

return resolveServableDocBytes({
rawBuffer: buffer,
fileName: filename,
Expand Down Expand Up @@ -117,10 +141,14 @@ export const GET = withRouteHandler(

const query = fileServeQuerySchema.parse({
raw: request.nextUrl.searchParams.get('raw'),
preview: request.nextUrl.searchParams.get('preview'),
v: request.nextUrl.searchParams.get('v'),
})
const raw = query.raw === '1'
const versioned = query.v != null
const options: ServeOptions = {
raw: query.raw === '1',
preview: query.preview === '1',
versioned: query.v != null,
}

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

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

if (isUsingCloudStorage()) {
return await handleCloudProxy(cloudKey, userId, raw, versioned, request.signal)
return await handleCloudProxy(cloudKey, userId, options, request.signal)
}

return await handleLocalFile(cloudKey, userId, raw, versioned, request.signal)
return await handleLocalFile(cloudKey, userId, options, request.signal)
} catch (error) {
// An in-progress/incomplete doc source fails to compile — this is expected
// mid-generation, not a server fault. Return 409 (not 500) so it isn't an
Expand All @@ -165,8 +193,7 @@ export const GET = withRouteHandler(
async function handleLocalFile(
filename: string,
userId: string,
raw: boolean,
versioned: boolean,
options: ServeOptions,
signal: AbortSignal | undefined
): Promise<NextResponse> {
const ownerKey = `user:${userId}`
Expand Down Expand Up @@ -198,22 +225,23 @@ async function handleLocalFile(
const segment = filename.split('/').pop() || filename
const displayName = stripStorageKeyPrefix(segment)
const workspaceId = getWorkspaceIdForCompile(filename)
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
rawBuffer,
displayName,
const { buffer: fileBuffer, contentType } = await resolveServableBytes({
buffer: rawBuffer,
filename: displayName,
storageKey: filename,
workspaceId,
raw,
options,
ownerKey,
signal
)
signal,
})

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

return createFileResponse({
buffer: fileBuffer,
contentType,
filename: displayName,
cacheControl: resolveServeCacheControl(versioned, contextParam),
cacheControl: resolveServeCacheControl(options.versioned, contextParam),
Comment thread
waleedlatif1 marked this conversation as resolved.
})
} catch (error) {
logger.error('Error reading local file:', error)
Expand All @@ -224,9 +252,8 @@ async function handleLocalFile(
async function handleCloudProxy(
cloudKey: string,
userId: string,
raw = false,
versioned = false,
signal: AbortSignal | undefined = undefined
options: ServeOptions,
signal: AbortSignal | undefined
): Promise<NextResponse> {
const ownerKey = `user:${userId}`
try {
Expand Down Expand Up @@ -260,14 +287,15 @@ async function handleCloudProxy(
const segment = cloudKey.split('/').pop() || 'download'
const displayName = stripStorageKeyPrefix(segment)
const workspaceId = getWorkspaceIdForCompile(cloudKey)
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
rawBuffer,
displayName,
const { buffer: fileBuffer, contentType } = await resolveServableBytes({
buffer: rawBuffer,
filename: displayName,
storageKey: cloudKey,
workspaceId,
raw,
options,
ownerKey,
signal
)
signal,
})

logger.info('Cloud file served', {
userId,
Expand All @@ -280,7 +308,7 @@ async function handleCloudProxy(
buffer: fileBuffer,
contentType,
filename: displayName,
cacheControl: resolveServeCacheControl(versioned, context),
cacheControl: resolveServeCacheControl(options.versioned, context),
})
} catch (error) {
logger.error('Error downloading from cloud storage:', error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,15 @@ describe('resolveFileCategory — formats accepted on upload must be previewable
['image.bmp', 'image/bmp'],
['image.avif', 'image/avif'],
['favicon.ico', 'image/x-icon'],
['photo.heic', 'image/heic'],
['photo.heif', 'image/heif'],
])('%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>',
it.each(['image.tiff', 'scan.tif'])(
'%s stays unsupported — nothing decodes it on either side',
(filename) => {
expect(resolveFileCategory(null, filename)).toBe('unsupported')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])

/**
* 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.
* Formats the image viewer can show. Most are decoded by the browser directly;
* `.heic`/`.heif` are not — no browser outside Safari renders them — but the serve
* route transcodes them to JPEG, so an `<img>` pointed at it works.
*
* `.tif`/`.tiff` are accepted uploads and deliberately absent: nothing decodes them
* on either side, so they stay download-only rather than showing a broken image.
*/
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
'image/png',
Expand All @@ -58,6 +60,8 @@ const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
'image/bmp',
'image/x-icon',
'image/vnd.microsoft.icon',
'image/heic',
'image/heif',
])
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
'png',
Expand All @@ -68,6 +72,8 @@ const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
'avif',
'bmp',
'ico',
'heic',
'heif',
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
])

const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
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, resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
import { resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
import {
useWorkspaceFileBinary,
useWorkspaceFileContent,
Expand All @@ -28,6 +28,7 @@ import {
PreviewErrorBoundary,
PreviewLoadingFrame,
resolvePreviewError,
UnsupportedPreview,
} from './preview-shared'
import { TextEditor } from './text-editor'
import { useDocPreviewBinary } from './use-doc-preview-binary'
Expand Down Expand Up @@ -170,7 +171,7 @@ function FileViewerContent({
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
// read-only public path a large CSV is download-only.
if (isCsvStreamOnly(file)) {
return <UnsupportedPreview file={file} />
return <UnsupportedPreview name={file.name} />
}
// Markdown renders through the inline rich editor (non-editable) so the public share
// surface matches the in-app reading experience; canEdit={false} disables autosave,
Expand Down Expand Up @@ -259,7 +260,7 @@ function FileViewerContent({
return <XlsxPreview key={file.id} file={file} workspaceId={workspaceId} />
}

return <UnsupportedPreview file={file} />
return <UnsupportedPreview name={file.name} />
}

/**
Expand Down Expand Up @@ -421,22 +422,3 @@ const MediaPreview = memo(function MediaPreview({
</div>
)
})

const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {
file: WorkspaceFileRecord
}) {
const ext = getFileExtension(file.name)

return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
</p>
<p className='text-[13px] text-[var(--text-muted)]'>
Use the download button to view this file
</p>
</div>
)
})
Loading
Loading