@@ -9,17 +9,28 @@ import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
99import { parseRequest } from '@/lib/api/server'
1010import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1111import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
12+ import { MATERIALIZE_CONCURRENCY , mapWithConcurrency } from '@/lib/core/utils/concurrency'
1213import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1314import { captureServerEvent } from '@/lib/posthog/server'
1415import type { StorageContext } from '@/lib/uploads/config'
1516import { getServeStoragePrefix } from '@/lib/uploads/config'
1617import { downloadFile } from '@/lib/uploads/core/storage-service'
1718import { getFileMetadataById } from '@/lib/uploads/server/metadata'
19+ import { formatFileSize } from '@/lib/uploads/utils/file-utils'
1820import { verifyFileAccess } from '@/app/api/files/authorization'
1921import { encodeFilenameForHeader } from '@/app/api/files/utils'
2022
2123const 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+
2334const MARKDOWN_MIME_TYPES = new Set ( [ 'text/markdown' , 'text/x-markdown' ] )
2435const 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