Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
704d06f
v0
Sg312 Mar 22, 2026
8abb884
Fix ppt load
Sg312 Mar 22, 2026
b28556f
Fixes
Sg312 Mar 22, 2026
dde64aa
Fixes
Sg312 Mar 22, 2026
4a537ff
Fix lint
Sg312 Mar 22, 2026
aa9fc10
Fix wid
Sg312 Mar 22, 2026
5954abd
Download image
Sg312 Mar 22, 2026
77a4f2f
Update tools
Sg312 Mar 23, 2026
d071248
Fix lint
Sg312 Mar 23, 2026
844c9a2
Fix error msg
Sg312 Mar 23, 2026
46e8964
Tool fixes
Sg312 Mar 23, 2026
6549a50
Reenable subagent stream
Sg312 Mar 23, 2026
98f4dfd
Subagent stream
Sg312 Mar 23, 2026
1e7a987
Fix edit workflow hydration
Sg312 Mar 23, 2026
0b3000a
Throw func execute error on error
Sg312 Mar 23, 2026
e21a987
Sandbox PPTX generation in subprocess with vm.createContext
waleedlatif1 Mar 23, 2026
cc50d1e
upgrade deps, file viewer
waleedlatif1 Mar 23, 2026
119d5a5
Fix auth bypass, SSRF, and wrong size limit comment
waleedlatif1 Mar 23, 2026
cc214cd
Fix Buffer not assignable to BodyInit in preview route
waleedlatif1 Mar 23, 2026
c5f73a7
Fix SSRF bypass, IPv6 coverage, download size cap, and missing deps
waleedlatif1 Mar 23, 2026
ad2dab1
Replace hand-rolled SSRF guard with secureFetchWithValidation
waleedlatif1 Mar 23, 2026
51b47f1
Fix streaming preview cache ordering and patch ambiguity
waleedlatif1 Mar 23, 2026
7ab98e2
Fix subprocess env leak, unbounded preview spawning, and dead code
waleedlatif1 Mar 23, 2026
6ffb4b9
Wire abort signal through to subprocess and correct security comment
waleedlatif1 Mar 23, 2026
5de3616
Remove implementation-specific comments from pptx worker files
waleedlatif1 Mar 23, 2026
1e87ae3
Fix pre-aborted signal, pptx-worker tracing, and binary fetch cache
waleedlatif1 Mar 23, 2026
7a293c7
Lazy worker path resolution, code size cap, unused param prefix
waleedlatif1 Mar 23, 2026
5712ac4
Add cache-busting timestamp to binary file fetch
waleedlatif1 Mar 23, 2026
3c555af
Fix PPTX cache key stability and attribute-order-independent dimensio…
waleedlatif1 Mar 23, 2026
e97f163
ran lint
waleedlatif1 Mar 23, 2026
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
58 changes: 48 additions & 10 deletions apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generatePptxFromCode } from '@/lib/copilot/tools/server/files/workspace-file'
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { downloadFile } from '@/lib/uploads/core/storage-service'
Expand All @@ -18,6 +19,27 @@ import {

const logger = createLogger('FilesServeAPI')

const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04])

async function compilePptxIfNeeded(
buffer: Buffer,
filename: string,
workspaceId?: string,
raw?: boolean
): Promise<{ buffer: Buffer; contentType: string }> {
const isPptx = filename.toLowerCase().endsWith('.pptx')
if (raw || !isPptx || buffer.subarray(0, 4).equals(ZIP_MAGIC)) {
return { buffer, contentType: getContentType(filename) }
}

const code = buffer.toString('utf-8')
const compiled = await generatePptxFromCode(code, workspaceId || '')
return {
buffer: compiled,
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
}
Comment thread
Sg312 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
}

const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/

function stripStorageKeyPrefix(segment: string): string {
Expand All @@ -44,6 +66,7 @@ export async function GET(
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath

const contextParam = request.nextUrl.searchParams.get('context')
const raw = request.nextUrl.searchParams.get('raw') === '1'

const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)

Expand All @@ -68,10 +91,10 @@ export async function GET(
const userId = authResult.userId

if (isUsingCloudStorage()) {
return await handleCloudProxy(cloudKey, userId, contextParam)
return await handleCloudProxy(cloudKey, userId, contextParam, raw)
}

return await handleLocalFile(cloudKey, userId)
return await handleLocalFile(cloudKey, userId, raw)
} catch (error) {
logger.error('Error serving file:', error)

Expand All @@ -83,7 +106,11 @@ export async function GET(
}
}

async function handleLocalFile(filename: string, userId: string): Promise<NextResponse> {
async function handleLocalFile(
filename: string,
userId: string,
raw: boolean
): Promise<NextResponse> {
try {
const contextParam: StorageContext | undefined = inferContextFromKey(filename) as
| StorageContext
Expand All @@ -108,10 +135,15 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
throw new FileNotFoundError(`File not found: ${filename}`)
}

const fileBuffer = await readFile(filePath)
const rawBuffer = await readFile(filePath)
const segment = filename.split('/').pop() || filename
const displayName = stripStorageKeyPrefix(segment)
const contentType = getContentType(displayName)
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
rawBuffer,
displayName,
undefined,
raw
)
Comment thread
Sg312 marked this conversation as resolved.

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

Expand All @@ -130,7 +162,8 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
async function handleCloudProxy(
cloudKey: string,
userId: string,
contextParam?: string | null
contextParam?: string | null,
raw = false
): Promise<NextResponse> {
try {
let context: StorageContext
Expand All @@ -156,20 +189,25 @@ async function handleCloudProxy(
throw new FileNotFoundError(`File not found: ${cloudKey}`)
}

let fileBuffer: Buffer
let rawBuffer: Buffer

if (context === 'copilot') {
fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
rawBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
} else {
fileBuffer = await downloadFile({
rawBuffer = await downloadFile({
key: cloudKey,
context,
})
}

const segment = cloudKey.split('/').pop() || 'download'
const displayName = stripStorageKeyPrefix(segment)
const contentType = getContentType(displayName)
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
rawBuffer,
displayName,
undefined,
raw
)

logger.info('Cloud file served', {
userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import {
useUpdateWorkspaceFileContent,
useWorkspaceFileBinary,
useWorkspaceFileContent,
} from '@/hooks/queries/workspace-files'
import { useAutosave } from '@/hooks/use-autosave'
Expand Down Expand Up @@ -48,17 +49,29 @@ 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'])

type FileCategory = 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'unsupported'
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
])
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])

type FileCategory =
| 'text-editable'
| 'iframe-previewable'
| 'image-previewable'
| 'pptx-previewable'
| 'unsupported'

function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'

const ext = getFileExtension(filename)
if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable'
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'

return 'unsupported'
}
Expand Down Expand Up @@ -124,6 +137,10 @@ export function FileViewer({
return <ImagePreview file={file} />
}

if (category === 'pptx-previewable') {
return <PptxPreview file={file} workspaceId={workspaceId} streamingContent={streamingContent} />
}

return <UnsupportedPreview file={file} />
}

Expand Down Expand Up @@ -163,7 +180,7 @@ function TextEditor({
isLoading,
error,
dataUpdatedAt,
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
} = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs')

const updateContent = useUpdateWorkspaceFileContent()

Expand Down Expand Up @@ -417,6 +434,184 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
)
}

const pptxSlideCache = new Map<string, string[]>()

function pptxCacheKey(fileId: string, dataUpdatedAt: number): string {
return `${fileId}:${dataUpdatedAt}`
}
Comment thread
waleedlatif1 marked this conversation as resolved.

function pptxCacheSet(key: string, slides: string[]): void {
pptxSlideCache.set(key, slides)
if (pptxSlideCache.size > 5) {
const oldest = pptxSlideCache.keys().next().value
if (oldest !== undefined) pptxSlideCache.delete(oldest)
}
}

async function renderPptxSlides(
data: Uint8Array,
onSlide: (src: string, index: number) => void,
cancelled: () => boolean
): Promise<void> {
const { PPTXViewer } = await import('pptxviewjs')
if (cancelled()) return

const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = Math.round(1920 * dpr)
const H = Math.round(1080 * dpr)

const canvas = document.createElement('canvas')
canvas.width = W
canvas.height = H
const viewer = new PPTXViewer({ canvas })
await viewer.loadFile(data)
const count = viewer.getSlideCount()
if (cancelled() || count === 0) return

for (let i = 0; i < count; i++) {
if (cancelled()) break
if (i === 0) await viewer.render()
else await viewer.goToSlide(i)
onSlide(canvas.toDataURL('image/jpeg', 0.85), i)
}
}

function PptxPreview({
file,
workspaceId,
streamingContent,
}: {
file: WorkspaceFileRecord
workspaceId: string
streamingContent?: string
}) {
const {
data: fileData,
isLoading: isFetching,
error: fetchError,
dataUpdatedAt,
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)

const cacheKey = pptxCacheKey(file.id, dataUpdatedAt)
const cached = pptxSlideCache.get(cacheKey)

const [slides, setSlides] = useState<string[]>(cached ?? [])
const [rendering, setRendering] = useState(false)
const [renderError, setRenderError] = useState<string | null>(null)

useEffect(() => {
if (cached) {
setSlides(cached)
return
}

let cancelled = false

async function render() {
try {
setRendering(true)
setRenderError(null)

if (streamingContent !== undefined) {
const PptxGenJS = (await import('pptxgenjs')).default
const pptx = new PptxGenJS()
const fn = new Function('pptx', `return (async () => { ${streamingContent} })()`)
await fn(pptx)
const arrayBuffer = (await pptx.write({ outputType: 'arraybuffer' })) as ArrayBuffer
if (cancelled) return
const data = new Uint8Array(arrayBuffer)
const images: string[] = []
await renderPptxSlides(
data,
(src) => {
images.push(src)
if (!cancelled) setSlides([...images])
},
() => cancelled
)
return
}
Comment thread
waleedlatif1 marked this conversation as resolved.

if (!fileData) return
const data = new Uint8Array(fileData)
const images: string[] = []
await renderPptxSlides(
Comment thread
waleedlatif1 marked this conversation as resolved.
data,
(src) => {
images.push(src)
if (!cancelled) setSlides([...images])
},
() => cancelled
)
if (!cancelled && images.length > 0) {
pptxCacheSet(cacheKey, images)
}
} catch (err) {
if (!cancelled) {
const msg = err instanceof Error ? err.message : 'Failed to render presentation'
logger.error('PPTX render failed', { error: msg })
setRenderError(msg)
}
} finally {
if (!cancelled) setRendering(false)
}
}

render()
return () => {
cancelled = true
}
}, [fileData, dataUpdatedAt, streamingContent, cacheKey])

const error = fetchError
? fetchError instanceof Error
? fetchError.message
Comment thread
waleedlatif1 marked this conversation as resolved.
: 'Failed to load file'
: renderError
const loading = isFetching || rendering

if (error) {
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-body)]'>
Failed to preview presentation
</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}

if (loading && slides.length === 0) {
return (
<div className='flex flex-1 items-center justify-center bg-[var(--surface-1)]'>
<div className='flex flex-col items-center gap-[8px]'>
<div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
<p className='text-[13px] text-[var(--text-muted)]'>Loading presentation...</p>
</div>
</div>
)
}

return (
<div className='flex-1 overflow-y-auto bg-[var(--surface-1)] p-[24px]'>
<div className='mx-auto flex max-w-[960px] flex-col gap-[16px]'>
{slides.map((src, i) => (
<img key={i} src={src} alt={`Slide ${i + 1}`} className='w-full rounded-md shadow-lg' />
))}
</div>
</div>
)
}

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

Expand Down
Loading
Loading