Skip to content

Commit 8cd51d4

Browse files
authored
fix(files): preserve editor content and isolate shared image reads (#7582)
* fix(files): preserve editor content and isolate shared image reads * fix(files): preserve source drafts and image export fidelity
1 parent 3c909b3 commit 8cd51d4

36 files changed

Lines changed: 2628 additions & 232 deletions

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

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
/**
22
* @vitest-environment node
33
*/
4+
5+
import { recordAudit } from '@sim/audit'
46
import { createMockRequest } from '@sim/testing'
57
import JSZip from 'jszip'
68
import { beforeEach, describe, expect, it, vi } from 'vitest'
79
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
10+
import { getServeStoragePrefix } from '@/lib/uploads/config'
811

912
const {
1013
mockCheckAuth,
@@ -92,6 +95,62 @@ beforeEach(() => {
9295
})
9396

9497
describe('markdown export bundling', () => {
98+
it('rejects an unauthenticated request before reading file metadata', async () => {
99+
mockCheckAuth.mockResolvedValue({ success: false })
100+
const response = await GET(request(), context)
101+
102+
expect(response.status).toBe(401)
103+
expect(await response.json()).toEqual({ error: 'Unauthorized' })
104+
expect(mockGetFileMetadataById).not.toHaveBeenCalled()
105+
expect(recordAudit).not.toHaveBeenCalled()
106+
})
107+
108+
it('preserves legacy missing-file and access-denied responses', async () => {
109+
mockGetFileMetadataById.mockResolvedValueOnce(null)
110+
const missing = await GET(request(), context)
111+
expect(missing.status).toBe(404)
112+
expect(await missing.json()).toEqual({ error: 'Not found' })
113+
114+
mockVerifyFileAccess.mockResolvedValue(false)
115+
const forbidden = await GET(request(), context)
116+
expect(forbidden.status).toBe(403)
117+
expect(await forbidden.json()).toEqual({ error: 'Forbidden' })
118+
expect(mockDownloadFile).not.toHaveBeenCalled()
119+
expect(recordAudit).not.toHaveBeenCalled()
120+
})
121+
122+
it('keeps non-Markdown downloads as authorized serve redirects', async () => {
123+
mockGetFileMetadataById.mockResolvedValue({
124+
...DOC_RECORD,
125+
originalName: 'report.pdf',
126+
contentType: 'application/pdf',
127+
context: 'chat',
128+
})
129+
const response = await GET(request(), context)
130+
131+
expect(response.status).toBe(302)
132+
expect(response.headers.get('location')).toContain(
133+
`/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(DOC_RECORD.key)}`
134+
)
135+
expect(mockDownloadFile).not.toHaveBeenCalled()
136+
expect(recordAudit).toHaveBeenCalledWith(
137+
expect.objectContaining({
138+
metadata: expect.objectContaining({ format: 'file', assetCount: 0 }),
139+
})
140+
)
141+
})
142+
143+
it('preserves exact plain Markdown bytes and download headers', async () => {
144+
const content = '\uFEFF---\r\ntitle: "Résumé"\r\n---\r\n\r\n# 你好 😀\r\n'
145+
mockDownloadFile.mockResolvedValue(Buffer.from(content))
146+
const response = await GET(request(), context)
147+
148+
expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from(content))
149+
expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8')
150+
expect(response.headers.get('content-length')).toBe(String(Buffer.byteLength(content)))
151+
expect(response.headers.get('content-disposition')).toContain('doc.md')
152+
})
153+
95154
it('rejects on declared asset bytes before downloading any of them', async () => {
96155
embeds('a', 'b', 'c')
97156
assetsResolveTo((id) => assetRecord(id, 100 * MB))
@@ -107,12 +166,26 @@ describe('markdown export bundling', () => {
107166
it('counts the document body against the export limit, not just its assets', async () => {
108167
// Assets alone sit under the cap; the body is what carries the bundle over it.
109168
embeds('a')
110-
mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB))
169+
mockDownloadFile.mockResolvedValue(Buffer.alloc(2 * MB))
170+
assetsResolveTo((id) => assetRecord(id, 249 * MB))
111171

112172
const response = await GET(request(), context)
113173

114174
expect(response.status).toBe(400)
115175
expect((await response.json()).error).toContain('document and its embedded files')
176+
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
177+
})
178+
179+
it('downloads large Markdown verbatim without parsing it or querying assets', async () => {
180+
const content = Buffer.alloc(11 * MB, 'a')
181+
mockDownloadFile.mockResolvedValue(content)
182+
const response = await GET(request(), context)
183+
expect(response.status).toBe(200)
184+
expect(Buffer.from(await response.arrayBuffer()).equals(content)).toBe(true)
185+
expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8')
186+
expect(mockExtractEmbeddedFileRefs).not.toHaveBeenCalled()
187+
expect(mockGetFileMetadataById).toHaveBeenCalledTimes(1)
188+
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
116189
})
117190

118191
it('caps the document body read rather than loading it unbounded', async () => {
@@ -148,6 +221,23 @@ describe('markdown export bundling', () => {
148221
expect(assetCall?.[0].maxBytes).toBe(25 * MB)
149222
})
150223

224+
it('rejects actual aggregate bytes that exceed underreported asset metadata', async () => {
225+
const ids = Array.from({ length: 30 }, (_, index) => `image-${index}`)
226+
embeds(...ids)
227+
assetsResolveTo((id) => assetRecord(id, 1))
228+
const asset = Buffer.alloc(25 * MB)
229+
mockDownloadFile.mockImplementation(async ({ key }: { key: string }) =>
230+
key === DOC_RECORD.key ? Buffer.from('# Doc\n') : asset
231+
)
232+
233+
const response = await GET(request(), context)
234+
235+
expect(response.status).toBe(400)
236+
expect((await response.json()).error).toContain('exceeds')
237+
expect(recordAudit).not.toHaveBeenCalled()
238+
expect(mockDownloadFile.mock.calls.length).toBeLessThan(ids.length + 1)
239+
})
240+
151241
it('drops an unreadable asset instead of failing the whole export', async () => {
152242
embeds('good', 'bad')
153243
mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {

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

Lines changed: 33 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import path from 'node:path'
21
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
32
import { createLogger } from '@sim/logger'
43
import { toError } from '@sim/utils/errors'
5-
import JSZip from 'jszip'
64
import type { NextRequest } from 'next/server'
75
import { NextResponse } from 'next/server'
86
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
@@ -16,6 +14,14 @@ import type { StorageContext } from '@/lib/uploads/config'
1614
import { getServeStoragePrefix } from '@/lib/uploads/config'
1715
import { downloadFile } from '@/lib/uploads/core/storage-service'
1816
import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
17+
import {
18+
createMarkdownExport,
19+
MAX_EXPORT_MARKDOWN_PARSE_BYTES,
20+
MAX_EXPORT_TOTAL_BYTES,
21+
type MarkdownExportAsset,
22+
type MarkdownExportResult,
23+
MarkdownExportSizeError,
24+
} from '@/lib/uploads/server/markdown-export'
1925
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
2026
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
2127
import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref'
@@ -25,18 +31,6 @@ import { encodeFilenameForHeader } from '@/app/api/files/utils'
2531

2632
const logger = createLogger('FilesExportAPI')
2733

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-
4034
const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
4135
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
4236

@@ -46,22 +40,6 @@ function isMarkdown(originalName: string, contentType: string): boolean {
4640
return MARKDOWN_EXTENSIONS.has(ext)
4741
}
4842

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-
6543
export const GET = withRouteHandler(
6644
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
6745
const parsed = await parseRequest(fileExportContract, request, context)
@@ -152,11 +130,12 @@ export const GET = withRouteHandler(
152130
{ status: 400 }
153131
)
154132
}
155-
let mdContent = mdBuffer.toString('utf-8')
156-
157133
// Ids only: a serve-URL embed names a storage key, which the bundler has no id to rewrite the
158134
// markdown against, so those images stay pointed at their original URL.
159-
const { ids: imageIds } = extractEmbeddedFileRefs(mdContent)
135+
const imageIds =
136+
mdBuffer.length <= MAX_EXPORT_MARKDOWN_PARSE_BYTES
137+
? extractEmbeddedFileRefs(mdBuffer.toString('utf-8')).ids
138+
: []
160139

161140
logger.info('Exporting markdown', { id, imageCount: imageIds.length })
162141

@@ -174,106 +153,42 @@ export const GET = withRouteHandler(
174153
) {
175154
return null
176155
}
177-
return { imageId, record: imgRecord, size: getWorkspaceFileSize(imgRecord) }
178-
} catch (error) {
179-
logger.warn('Failed to resolve asset for export', {
156+
return {
180157
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({
206158
key: imgRecord.key,
207159
context: imgRecord.context as StorageContext,
208-
maxBytes: MAX_EXPORT_ASSET_BYTES,
209-
})
210-
return { imageId, originalName: imgRecord.originalName, buffer }
160+
originalName: imgRecord.originalName,
161+
size: getWorkspaceFileSize(imgRecord),
162+
} satisfies MarkdownExportAsset
211163
} 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', {
164+
logger.warn('Failed to resolve asset for export', {
215165
imageId,
216166
error: toError(error).message,
217167
})
218168
return null
219169
}
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-
},
247170
})
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-
}
171+
).filter((target): target is NonNullable<typeof target> => target !== null)
259172

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)
173+
let exported: MarkdownExportResult
174+
try {
175+
exported = await createMarkdownExport({
176+
content: mdBuffer,
177+
fileName: record.originalName,
178+
assets: assetTargets,
179+
})
180+
} catch (error) {
181+
if (!(error instanceof MarkdownExportSizeError)) throw error
182+
return NextResponse.json({ error: error.message }, { status: 400 })
265183
}
266184

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), {
185+
auditExport(exported.format, exported.assetCount)
186+
return new NextResponse(new Uint8Array(exported.buffer), {
272187
status: 200,
273188
headers: {
274-
'Content-Type': 'application/zip',
275-
'Content-Disposition': `attachment; ${encodeFilenameForHeader(zipName)}`,
276-
'Content-Length': String(zipBuffer.length),
189+
'Content-Type': exported.contentType,
190+
'Content-Disposition': `attachment; ${encodeFilenameForHeader(exported.fileName)}`,
191+
'Content-Length': String(exported.buffer.length),
277192
},
278193
})
279194
}

0 commit comments

Comments
 (0)