1+ import path from 'node:path'
12import { createLogger } from '@sim/logger'
23import { toError } from '@sim/utils/errors'
34import JSZip from 'jszip'
@@ -7,6 +8,7 @@ import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
78import { parseRequest } from '@/lib/api/server'
89import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
910import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+ import type { StorageContext } from '@/lib/uploads/config'
1012import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
1113import { downloadFile } from '@/lib/uploads/core/storage-service'
1214import { getFileMetadataById } from '@/lib/uploads/server/metadata'
@@ -18,13 +20,30 @@ const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
1820const MARKDOWN_EXTENSIONS = new Set ( [ 'md' , 'markdown' ] )
1921const VIEW_URL_RE =
2022 / \/ a p i \/ f i l e s \/ v i e w \/ ( [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } ) / gi
23+ const MAX_EMBEDDED_IMAGES = 50
2124
2225function 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+
2847export 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