1- import path from 'node:path'
21import { AuditAction , AuditResourceType , recordAudit } from '@sim/audit'
32import { createLogger } from '@sim/logger'
43import { toError } from '@sim/utils/errors'
5- import JSZip from 'jszip'
64import type { NextRequest } from 'next/server'
75import { NextResponse } from 'next/server'
86import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
@@ -16,6 +14,13 @@ import type { StorageContext } from '@/lib/uploads/config'
1614import { getServeStoragePrefix } from '@/lib/uploads/config'
1715import { downloadFile } from '@/lib/uploads/core/storage-service'
1816import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
17+ import {
18+ createMarkdownExport ,
19+ MAX_EXPORT_TOTAL_BYTES ,
20+ type MarkdownExportAsset ,
21+ type MarkdownExportResult ,
22+ MarkdownExportSizeError ,
23+ } from '@/lib/uploads/server/markdown-export'
1924import { getFileMetadataById } from '@/lib/uploads/server/metadata'
2025import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
2126import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref'
@@ -25,18 +30,6 @@ import { encodeFilenameForHeader } from '@/app/api/files/utils'
2530
2631const logger = createLogger ( 'FilesExportAPI' )
2732
28- /**
29- * Byte ceilings for a bundled export. The bytes behind an embed list are whatever the
30- * author put there, so without these the export would materialize unbounded assets in
31- * one request. They match the bulk-download route, so the two export surfaces reject at
32- * the same size.
33- *
34- * There is deliberately no count cap here: `extractEmbeddedFileRefs` already stops at
35- * `MAX_EMBEDDED_IMAGES`, so the list this route receives is bounded before it arrives.
36- */
37- const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024
38- const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024
39-
4033const MARKDOWN_MIME_TYPES = new Set ( [ 'text/markdown' , 'text/x-markdown' ] )
4134const MARKDOWN_EXTENSIONS = new Set ( [ 'md' , 'markdown' ] )
4235
@@ -46,22 +39,6 @@ function isMarkdown(originalName: string, contentType: string): boolean {
4639 return MARKDOWN_EXTENSIONS . has ( ext )
4740}
4841
49- function safeFilename ( name : string ) : string {
50- return path
51- . basename ( name )
52- . replace ( / [ " \\ ] / g, '_' )
53- . replace ( / [ \r \n \t ] / g, '' )
54- }
55-
56- function deduplicatedFilename ( preferred : string , existing : Set < string > , imageId : string ) : string {
57- if ( ! existing . has ( preferred ) ) return preferred
58- const ext = path . extname ( preferred )
59- const base = path . basename ( preferred , ext )
60- const short = `${ base } _${ imageId . slice ( 0 , 8 ) } ${ ext } `
61- if ( ! existing . has ( short ) ) return short
62- return `${ base } _${ imageId } ${ ext } `
63- }
64-
6542export const GET = withRouteHandler (
6643 async ( request : NextRequest , context : { params : Promise < { id : string } > } ) => {
6744 const parsed = await parseRequest ( fileExportContract , request , context )
@@ -152,7 +129,7 @@ export const GET = withRouteHandler(
152129 { status : 400 }
153130 )
154131 }
155- let mdContent = mdBuffer . toString ( 'utf-8' )
132+ const mdContent = mdBuffer . toString ( 'utf-8' )
156133
157134 // Ids only: a serve-URL embed names a storage key, which the bundler has no id to rewrite the
158135 // markdown against, so those images stay pointed at their original URL.
@@ -174,106 +151,42 @@ export const GET = withRouteHandler(
174151 ) {
175152 return null
176153 }
177- return { imageId, record : imgRecord , size : getWorkspaceFileSize ( imgRecord ) }
178- } catch ( error ) {
179- logger . warn ( 'Failed to resolve asset for export' , {
154+ return {
180155 imageId,
181- error : toError ( error ) . message ,
182- } )
183- return null
184- }
185- } )
186- ) . filter ( ( target ) : target is NonNullable < typeof target > => target !== null )
187-
188- // The body counts against the same budget as its assets — the zip holds both, so a
189- // limit that measured only the attachments would not describe the archive produced.
190- const bundleBytes = mdBuffer . length + assetTargets . reduce ( ( sum , target ) => sum + target . size , 0 )
191- if ( bundleBytes > MAX_EXPORT_TOTAL_BYTES ) {
192- return NextResponse . json (
193- {
194- error : `This document and its embedded files total ${ formatFileSize ( bundleBytes ) } , which exceeds the ${ formatFileSize ( MAX_EXPORT_TOTAL_BYTES ) } export limit.` ,
195- } ,
196- { status : 400 }
197- )
198- }
199-
200- const fetched = await mapWithConcurrency (
201- assetTargets ,
202- MATERIALIZE_CONCURRENCY ,
203- async ( { imageId, record : imgRecord } ) => {
204- try {
205- const buffer = await downloadFile ( {
206156 key : imgRecord . key ,
207157 context : imgRecord . context as StorageContext ,
208- maxBytes : MAX_EXPORT_ASSET_BYTES ,
209- } )
210- return { imageId , originalName : imgRecord . originalName , buffer }
158+ originalName : imgRecord . originalName ,
159+ size : getWorkspaceFileSize ( imgRecord ) ,
160+ } satisfies MarkdownExportAsset
211161 } catch ( error ) {
212- // A single unreadable or oversized asset drops out of the bundle rather than
213- // failing the whole export; the markdown keeps its original link.
214- logger . warn ( 'Failed to fetch asset for export' , {
162+ logger . warn ( 'Failed to resolve asset for export' , {
215163 imageId,
216164 error : toError ( error ) . message ,
217165 } )
218166 return null
219167 }
220- }
221- )
222-
223- const assetMap = new Map < string , { filename : string ; buffer : Buffer } > ( )
224- const usedFilenames = new Set < string > ( )
225-
226- for ( const result of fetched ) {
227- if ( ! result ) continue
228- const { imageId, originalName, buffer } = result
229- const preferred = safeFilename ( originalName )
230- const filename = deduplicatedFilename ( preferred , usedFilenames , imageId )
231- usedFilenames . add ( filename )
232- assetMap . set ( imageId , { filename, buffer } )
233- }
234-
235- // Format follows what was bundled, not what was referenced: an embed can point at a file that is
236- // missing, unreadable, or oversized, and an empty `assets/` zip is a worse answer than the
237- // document itself. `mdContent` is still unrewritten here, so `mdBuffer` holds exactly its bytes.
238- if ( assetMap . size === 0 ) {
239- auditExport ( 'markdown' , 0 )
240- return new NextResponse ( new Uint8Array ( mdBuffer ) , {
241- status : 200 ,
242- headers : {
243- 'Content-Type' : 'text/markdown; charset=utf-8' ,
244- 'Content-Disposition' : `attachment; ${ encodeFilenameForHeader ( safeFilename ( record . originalName ) ) } ` ,
245- 'Content-Length' : String ( mdBuffer . length ) ,
246- } ,
247168 } )
248- }
249-
250- for ( const [ imageId , asset ] of assetMap ) {
251- const escapedId = imageId . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' )
252- const replacement = `./assets/${ asset . filename } `
253- // Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app
254- // `/workspace/<ws>/files/<id>` path — so a bundled asset never leaves a broken link in the export.
255- mdContent = mdContent
256- . replace ( new RegExp ( `/api/files/view/${ escapedId } ` , 'g' ) , ( ) => replacement )
257- . replace ( new RegExp ( `/workspace/[A-Za-z0-9-]+/files/${ escapedId } ` , 'g' ) , ( ) => replacement )
258- }
169+ ) . filter ( ( target ) : target is NonNullable < typeof target > => target !== null )
259170
260- const zip = new JSZip ( )
261- zip . file ( safeFilename ( record . originalName ) , mdContent )
262- const assetsFolder = zip . folder ( 'assets' ) !
263- for ( const { filename, buffer } of assetMap . values ( ) ) {
264- assetsFolder . file ( filename , buffer )
171+ let exported : MarkdownExportResult
172+ try {
173+ exported = await createMarkdownExport ( {
174+ content : mdBuffer ,
175+ fileName : record . originalName ,
176+ assets : assetTargets ,
177+ } )
178+ } catch ( error ) {
179+ if ( ! ( error instanceof MarkdownExportSizeError ) ) throw error
180+ return NextResponse . json ( { error : error . message } , { status : 400 } )
265181 }
266182
267- const zipBuffer = await zip . generateAsync ( { type : 'nodebuffer' , compression : 'DEFLATE' } )
268- const zipName = safeFilename ( `${ record . originalName . replace ( / \. [ ^ . ] + $ / , '' ) } .zip` )
269-
270- auditExport ( 'zip' , assetMap . size )
271- return new NextResponse ( new Uint8Array ( zipBuffer ) , {
183+ auditExport ( exported . format , exported . assetCount )
184+ return new NextResponse ( new Uint8Array ( exported . buffer ) , {
272185 status : 200 ,
273186 headers : {
274- 'Content-Type' : 'application/zip' ,
275- 'Content-Disposition' : `attachment; ${ encodeFilenameForHeader ( zipName ) } ` ,
276- 'Content-Length' : String ( zipBuffer . length ) ,
187+ 'Content-Type' : exported . contentType ,
188+ 'Content-Disposition' : `attachment; ${ encodeFilenameForHeader ( exported . fileName ) } ` ,
189+ 'Content-Length' : String ( exported . buffer . length ) ,
277190 } ,
278191 } )
279192 }
0 commit comments