Skip to content

Commit 4f60de4

Browse files
committed
fix(files): sanitize zip filenames, fix storage context cast, cap embedded image count
1 parent 363521c commit 4f60de4

1 file changed

Lines changed: 37 additions & 7 deletions

File tree

  • apps/sim/app/api/files/export/[id]

apps/sim/app/api/files/export/[id]/route.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from 'node:path'
12
import { createLogger } from '@sim/logger'
23
import { toError } from '@sim/utils/errors'
34
import JSZip from 'jszip'
@@ -7,6 +8,7 @@ import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
78
import { parseRequest } from '@/lib/api/server'
89
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import type { StorageContext } from '@/lib/uploads/config'
1012
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
1113
import { downloadFile } from '@/lib/uploads/core/storage-service'
1214
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
@@ -18,13 +20,30 @@ const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
1820
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
1921
const VIEW_URL_RE =
2022
/\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
23+
const MAX_EMBEDDED_IMAGES = 50
2124

2225
function isMarkdown(originalName: string, contentType: string): boolean {
2326
if (MARKDOWN_MIME_TYPES.has(contentType)) return true
2427
const ext = originalName.split('.').pop()?.toLowerCase() ?? ''
2528
return MARKDOWN_EXTENSIONS.has(ext)
2629
}
2730

31+
/** Strip characters that would break Content-Disposition header or zip entry paths. */
32+
function safeFilename(name: string): string {
33+
return path
34+
.basename(name)
35+
.replace(/["\\]/g, '_')
36+
.replace(/[\r\n\t]/g, '')
37+
}
38+
39+
/** Deduplicate asset filename by appending the first 8 chars of its UUID when a collision exists. */
40+
function deduplicatedFilename(preferred: string, existing: Set<string>, imageId: string): string {
41+
if (!existing.has(preferred)) return preferred
42+
const ext = path.extname(preferred)
43+
const base = path.basename(preferred, ext)
44+
return `${base}_${imageId.slice(0, 8)}${ext}`
45+
}
46+
2847
export const GET = withRouteHandler(
2948
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
3049
const parsed = await parseRequest(fileExportContract, request, context)
@@ -55,13 +74,21 @@ export const GET = withRouteHandler(
5574
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
5675
}
5776

58-
const mdBuffer = await downloadFile({ key: record.key, context: record.context as 'workspace' })
77+
const mdBuffer = await downloadFile({
78+
key: record.key,
79+
context: record.context as StorageContext,
80+
})
5981
let mdContent = mdBuffer.toString('utf-8')
6082

61-
const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))]
83+
const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))].slice(
84+
0,
85+
MAX_EMBEDDED_IMAGES
86+
)
87+
6288
logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length })
6389

6490
const assetMap = new Map<string, { filename: string; buffer: Buffer }>()
91+
const usedFilenames = new Set<string>()
6592

6693
await Promise.allSettled(
6794
imageIds.map(async (imageId) => {
@@ -72,9 +99,12 @@ export const GET = withRouteHandler(
7299
if (!imgHasAccess) return
73100
const imgBuffer = await downloadFile({
74101
key: imgRecord.key,
75-
context: imgRecord.context as 'workspace',
102+
context: imgRecord.context as StorageContext,
76103
})
77-
assetMap.set(imageId, { filename: imgRecord.originalName, buffer: imgBuffer })
104+
const preferred = safeFilename(imgRecord.originalName)
105+
const filename = deduplicatedFilename(preferred, usedFilenames, imageId)
106+
usedFilenames.add(filename)
107+
assetMap.set(imageId, { filename, buffer: imgBuffer })
78108
} catch (err) {
79109
logger.warn('Failed to fetch asset for export', { imageId, error: toError(err).message })
80110
}
@@ -91,13 +121,13 @@ export const GET = withRouteHandler(
91121

92122
const zip = new JSZip()
93123
zip.file(record.originalName, mdContent)
94-
const assets = zip.folder('assets')!
124+
const assetsFolder = zip.folder('assets')!
95125
for (const { filename, buffer } of assetMap.values()) {
96-
assets.file(filename, buffer)
126+
assetsFolder.file(filename, buffer)
97127
}
98128

99129
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
100-
const zipName = `${record.originalName.replace(/\.[^.]+$/, '')}.zip`
130+
const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`)
101131

102132
return new NextResponse(zipBuffer, {
103133
status: 200,

0 commit comments

Comments
 (0)