Skip to content

Commit 37607d1

Browse files
committed
fix(files): bound the markdown export's embedded-asset bundling
The embed list is produced by scanning the document body, so its length and the bytes behind it are whatever the author put there. Every referenced asset was downloaded at once with no concurrency bound, no per-file cap and no total cap, so one request could materialize an unbounded number of unbounded files. Its sibling bulk-download route already had a count cap and a byte cap. Metadata is now resolved first, which bounds the download from declared sizes before a byte is read and moves the authorization check off the download path. Assets are then fetched with bounded concurrency and a per-file cap; a single unreadable or oversized asset drops out of the bundle rather than failing the export, which is what the previous allSettled did.
1 parent 3010449 commit 37607d1

1 file changed

Lines changed: 72 additions & 22 deletions

File tree

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

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

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,28 @@ import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
99
import { parseRequest } from '@/lib/api/server'
1010
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1111
import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
12+
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1314
import { captureServerEvent } from '@/lib/posthog/server'
1415
import type { StorageContext } from '@/lib/uploads/config'
1516
import { getServeStoragePrefix } from '@/lib/uploads/config'
1617
import { downloadFile } from '@/lib/uploads/core/storage-service'
1718
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
19+
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
1820
import { verifyFileAccess } from '@/app/api/files/authorization'
1921
import { encodeFilenameForHeader } from '@/app/api/files/utils'
2022

2123
const logger = createLogger('FilesExportAPI')
2224

25+
/**
26+
* Bundling caps. The embed list comes from scanning the document body, so its length
27+
* and the bytes behind it are whatever the author put there — without these the export
28+
* would materialize an unbounded number of unbounded assets in one request.
29+
*/
30+
const MAX_EXPORT_ASSETS = 100
31+
const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024
32+
const MAX_EXPORT_TOTAL_BYTES = 100 * 1024 * 1024
33+
2334
const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
2435
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
2536

@@ -136,34 +147,73 @@ export const GET = withRouteHandler(
136147
})
137148
}
138149

139-
const fetchResults = await Promise.allSettled(
140-
imageIds.map(async (imageId) => {
141-
const imgRecord = await getFileMetadataById(imageId)
142-
if (!imgRecord) return null
143-
const imgHasAccess = await verifyFileAccess(imgRecord.key, userId)
144-
if (!imgHasAccess) return null
145-
const imgBuffer = await downloadFile({
146-
key: imgRecord.key,
147-
context: imgRecord.context as StorageContext,
148-
})
149-
return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer }
150+
if (imageIds.length > MAX_EXPORT_ASSETS) {
151+
return NextResponse.json(
152+
{
153+
error: `This document embeds ${imageIds.length} files, more than the ${MAX_EXPORT_ASSETS} an export can bundle.`,
154+
},
155+
{ status: 400 }
156+
)
157+
}
158+
159+
// Metadata first: declared sizes bound the download before a byte is read, and the
160+
// authorization check costs nothing to run here.
161+
const assetTargets = (
162+
await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => {
163+
try {
164+
const imgRecord = await getFileMetadataById(imageId)
165+
if (!imgRecord) return null
166+
if (!(await verifyFileAccess(imgRecord.key, userId))) return null
167+
return { imageId, record: imgRecord }
168+
} catch (error) {
169+
logger.warn('Failed to resolve asset for export', {
170+
imageId,
171+
error: toError(error).message,
172+
})
173+
return null
174+
}
150175
})
176+
).filter((target): target is NonNullable<typeof target> => target !== null)
177+
178+
const declaredAssetBytes = assetTargets.reduce((sum, target) => sum + target.record.size, 0)
179+
if (declaredAssetBytes > MAX_EXPORT_TOTAL_BYTES) {
180+
return NextResponse.json(
181+
{
182+
error: `Embedded files total ${formatFileSize(declaredAssetBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`,
183+
},
184+
{ status: 400 }
185+
)
186+
}
187+
188+
const fetched = await mapWithConcurrency(
189+
assetTargets,
190+
MATERIALIZE_CONCURRENCY,
191+
async ({ imageId, record: imgRecord }) => {
192+
try {
193+
const buffer = await downloadFile({
194+
key: imgRecord.key,
195+
context: imgRecord.context as StorageContext,
196+
maxBytes: MAX_EXPORT_ASSET_BYTES,
197+
})
198+
return { imageId, originalName: imgRecord.originalName, buffer }
199+
} catch (error) {
200+
// A single unreadable or oversized asset drops out of the bundle rather than
201+
// failing the whole export; the markdown keeps its original link.
202+
logger.warn('Failed to fetch asset for export', {
203+
imageId,
204+
error: toError(error).message,
205+
})
206+
return null
207+
}
208+
}
151209
)
152210

153211
const assetMap = new Map<string, { filename: string; buffer: Buffer }>()
154212
const usedFilenames = new Set<string>()
155213

156-
for (let i = 0; i < fetchResults.length; i++) {
157-
const result = fetchResults[i]
158-
if (result.status === 'rejected') {
159-
logger.warn('Failed to fetch asset for export', {
160-
imageId: imageIds[i],
161-
error: toError(result.reason).message,
162-
})
163-
continue
164-
}
165-
if (!result.value) continue
166-
const { imageId, originalName, buffer } = result.value
214+
for (const result of fetched) {
215+
if (!result) continue
216+
const { imageId, originalName, buffer } = result
167217
const preferred = safeFilename(originalName)
168218
const filename = deduplicatedFilename(preferred, usedFilenames, imageId)
169219
usedFilenames.add(filename)

0 commit comments

Comments
 (0)