Skip to content

Commit b44d285

Browse files
authored
fix(files): download a markdown file as a zip only when it really has assets (#7009)
* fix(files): download a markdown file as a zip only when it really has assets A document that merely mentions an embed URL in prose or an inline code span counted as having attachments, so any document about the files API downloaded as a zip whose assets/ folder was empty. - Detect embeds with the markdown lexer instead of scanning raw text, so only real image embeds count: prose, code spans, fenced samples, and links no longer do - Choose the export format after resolving assets rather than from the candidate count, so a missing, unreadable, or oversized embed falls back to the plain document instead of an empty zip - Move the document scan out of the copilot tool tree into lib/uploads/server, where both file routes already live, and drop two pass-through wrappers - Share one <img> src reader between the clipboard handlers and the scan - Walk tokens explicitly: marked's walkTokens concatenates per token and costs O(n^2), measuring 5.4s on a 254KB document against 14ms here, on a path anonymous public-share traffic reaches * fix(files): keep an embed id spelled as the document spells it Decoding the id let a percent-encoded embed resolve and bundle its asset while the rewrite, which searches the document for that id, found nothing — the zip kept an API URL that renders as a broken image offline. Keys stay decoded; they are matched against stored keys, not against document text. * fix(files): resolve an export asset by its stored id, rewrite by its spelling An embed carries two representations and they are not interchangeable: metadata resolves by the stored id, while the rewrite finds the embed by searching the document for the spelling it used. Using one for both either drops a percent-encoded asset or bundles it behind a link still pointing at the API. * fix(files): resolve an embed by its stored id wherever one is read from a document The export bundler decoded an embed's spelling before looking it up, but the file-agent's embeddability warning did not, so a percent-encoded embed the export resolves and bundles could still be reported as one that will not survive an export. Both now share one helper. Request-supplied ids are untouched: their route contracts already constrain them to the plain id charset, so there is no spelling to decode.
1 parent ed335d4 commit b44d285

12 files changed

Lines changed: 411 additions & 233 deletions

File tree

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

Lines changed: 110 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,29 @@ const {
1111
mockGetFileMetadataById,
1212
mockVerifyFileAccess,
1313
mockDownloadFile,
14-
mockExtractEmbeddedImageIds,
14+
mockExtractEmbeddedFileRefs,
1515
} = vi.hoisted(() => ({
1616
mockCheckAuth: vi.fn(),
1717
mockGetFileMetadataById: vi.fn(),
1818
mockVerifyFileAccess: vi.fn(),
1919
mockDownloadFile: vi.fn(),
20-
mockExtractEmbeddedImageIds: vi.fn(),
20+
mockExtractEmbeddedFileRefs: vi.fn(),
2121
}))
2222

23+
/** `embedded-image-refs.test.ts` covers the grammar itself. */
24+
function embeds(...ids: string[]) {
25+
mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids })
26+
}
27+
2328
vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth }))
2429
vi.mock('@/lib/uploads/server/metadata', () => ({
2530
getFileMetadataById: mockGetFileMetadataById,
2631
}))
2732
vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess }))
2833
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
29-
vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({
30-
extractEmbeddedImageIds: mockExtractEmbeddedImageIds,
34+
vi.mock('@/lib/uploads/server/embedded-image-refs', () => ({
35+
extractEmbeddedFileRefs: mockExtractEmbeddedFileRefs,
36+
storedFileId: (spelledId: string) => decodeURIComponent(spelledId),
3137
}))
3238
vi.mock('@sim/audit', () => ({
3339
recordAudit: vi.fn(),
@@ -58,43 +64,35 @@ function assetRecord(id: string, size: number) {
5864
}
5965
}
6066

61-
describe('markdown export bundling', () => {
62-
beforeEach(() => {
63-
vi.clearAllMocks()
64-
mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' })
65-
mockVerifyFileAccess.mockResolvedValue(true)
66-
mockGetFileMetadataById.mockImplementation(async (id: string) =>
67-
id === DOC_ID
68-
? {
69-
id: DOC_ID,
70-
key: 'workspace/ws-1/doc.md',
71-
originalName: 'doc.md',
72-
contentType: 'text/markdown',
73-
context: 'workspace',
74-
size: 1024,
75-
workspaceId: 'ws-1',
76-
}
77-
: assetRecord(id, 1 * MB)
78-
)
79-
mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n'))
80-
mockExtractEmbeddedImageIds.mockReturnValue([])
81-
})
67+
const DOC_RECORD = {
68+
id: DOC_ID,
69+
key: 'workspace/ws-1/doc.md',
70+
originalName: 'doc.md',
71+
contentType: 'text/markdown',
72+
context: 'workspace',
73+
size: 1024,
74+
workspaceId: 'ws-1',
75+
}
76+
77+
function assetsResolveTo(assetFor: (id: string) => unknown) {
78+
mockGetFileMetadataById.mockImplementation(async (id: string) =>
79+
id === DOC_ID ? DOC_RECORD : assetFor(id)
80+
)
81+
}
82+
83+
beforeEach(() => {
84+
vi.clearAllMocks()
85+
mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' })
86+
mockVerifyFileAccess.mockResolvedValue(true)
87+
assetsResolveTo((id) => assetRecord(id, 1 * MB))
88+
mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n'))
89+
embeds()
90+
})
8291

92+
describe('markdown export bundling', () => {
8393
it('rejects on declared asset bytes before downloading any of them', async () => {
84-
mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c'])
85-
mockGetFileMetadataById.mockImplementation(async (id: string) =>
86-
id === DOC_ID
87-
? {
88-
id: DOC_ID,
89-
key: 'workspace/ws-1/doc.md',
90-
originalName: 'doc.md',
91-
contentType: 'text/markdown',
92-
context: 'workspace',
93-
size: 1024,
94-
workspaceId: 'ws-1',
95-
}
96-
: assetRecord(id, 100 * MB)
97-
)
94+
embeds('a', 'b', 'c')
95+
assetsResolveTo((id) => assetRecord(id, 100 * MB))
9896

9997
const response = await GET(request(), context)
10098

@@ -106,7 +104,7 @@ describe('markdown export bundling', () => {
106104

107105
it('counts the document body against the export limit, not just its assets', async () => {
108106
// Assets alone sit under the cap; the body is what carries the bundle over it.
109-
mockExtractEmbeddedImageIds.mockReturnValue(['a'])
107+
embeds('a')
110108
mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB))
111109

112110
const response = await GET(request(), context)
@@ -116,7 +114,7 @@ describe('markdown export bundling', () => {
116114
})
117115

118116
it('caps the document body read rather than loading it unbounded', async () => {
119-
mockExtractEmbeddedImageIds.mockReturnValue([])
117+
embeds()
120118

121119
await GET(request(), context)
122120

@@ -125,7 +123,7 @@ describe('markdown export bundling', () => {
125123
})
126124

127125
it('reports an oversized body as a size rejection, not a server error', async () => {
128-
mockExtractEmbeddedImageIds.mockReturnValue([])
126+
embeds()
129127
mockDownloadFile.mockRejectedValue(
130128
new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 })
131129
)
@@ -138,7 +136,7 @@ describe('markdown export bundling', () => {
138136
})
139137

140138
it('caps each asset download rather than trusting its declared size', async () => {
141-
mockExtractEmbeddedImageIds.mockReturnValue(['a'])
139+
embeds('a')
142140

143141
await GET(request(), context)
144142

@@ -149,7 +147,7 @@ describe('markdown export bundling', () => {
149147
})
150148

151149
it('drops an unreadable asset instead of failing the whole export', async () => {
152-
mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad'])
150+
embeds('good', 'bad')
153151
mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {
154152
if (key.endsWith('doc.md')) return Buffer.from('# Doc\n![x](/api/files/view/good)\n')
155153
if (key.endsWith('bad')) throw new Error('storage down')
@@ -164,8 +162,31 @@ describe('markdown export bundling', () => {
164162
expect(zip.file('assets/bad.png')).toBeNull()
165163
})
166164

165+
/**
166+
* The two id representations have to stay distinct: metadata resolves by the stored id, while the
167+
* rewrite finds the embed by the spelling the document used. Collapsing them either drops the
168+
* asset or bundles it behind a link still pointing at the API.
169+
*/
170+
it('resolves and rewrites an embed whose id is percent-encoded in the document', async () => {
171+
embeds('wf%5Fa')
172+
assetsResolveTo((id) => (id === 'wf_a' ? assetRecord(id, 1 * MB) : null))
173+
mockDownloadFile.mockImplementation(async ({ key }: { key: string }) =>
174+
key.endsWith('doc.md')
175+
? Buffer.from('# Doc\n![x](/api/files/view/wf%5Fa)\n')
176+
: Buffer.from('png-bytes')
177+
)
178+
179+
const response = await GET(request(), context)
180+
181+
const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
182+
expect(zip.file('assets/wf_a.png')).not.toBeNull()
183+
const md = await zip.file('doc.md')?.async('string')
184+
expect(md).toContain('./assets/wf_a.png')
185+
expect(md).not.toContain('/api/files/view/')
186+
})
187+
167188
it('skips an asset the caller cannot read', async () => {
168-
mockExtractEmbeddedImageIds.mockReturnValue(['secret'])
189+
embeds('secret')
169190
mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret'))
170191

171192
const response = await GET(request(), context)
@@ -177,3 +198,47 @@ describe('markdown export bundling', () => {
177198
)
178199
})
179200
})
201+
202+
describe('markdown export format', () => {
203+
async function expectPlainMarkdown(response: Response) {
204+
expect(response.status).toBe(200)
205+
expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
206+
expect(response.headers.get('Content-Disposition')).toContain('doc.md')
207+
expect(await response.text()).toBe('# Doc\n')
208+
}
209+
210+
it('returns the document itself when it embeds nothing', async () => {
211+
await expectPlainMarkdown(await GET(request(), context))
212+
})
213+
214+
/**
215+
* The reported bug: a document that references files which no longer resolve downloaded as a zip
216+
* whose `assets/` folder was empty. The format follows what was bundled, not what was referenced.
217+
*/
218+
it('returns the document itself when no embed resolves to a file', async () => {
219+
embeds('gone', 'also-gone')
220+
assetsResolveTo(() => null)
221+
222+
await expectPlainMarkdown(await GET(request(), context))
223+
})
224+
225+
it('returns the document itself when every embed fails to download', async () => {
226+
embeds('a')
227+
mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {
228+
if (key.endsWith('doc.md')) return Buffer.from('# Doc\n')
229+
throw new Error('storage down')
230+
})
231+
232+
await expectPlainMarkdown(await GET(request(), context))
233+
})
234+
235+
it('bundles a zip once at least one embed resolves', async () => {
236+
embeds('a')
237+
238+
const response = await GET(request(), context)
239+
240+
expect(response.headers.get('Content-Type')).toBe('application/zip')
241+
const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
242+
expect(zip.file('assets/a.png')).not.toBeNull()
243+
})
244+
})

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

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import { NextResponse } from 'next/server'
88
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
99
import { parseRequest } from '@/lib/api/server'
1010
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
11-
import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
1211
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
1312
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1413
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1514
import { captureServerEvent } from '@/lib/posthog/server'
1615
import type { StorageContext } from '@/lib/uploads/config'
1716
import { getServeStoragePrefix } from '@/lib/uploads/config'
1817
import { downloadFile } from '@/lib/uploads/core/storage-service'
18+
import { extractEmbeddedFileRefs, storedFileId } from '@/lib/uploads/server/embedded-image-refs'
1919
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
2020
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
2121
import { verifyFileAccess } from '@/app/api/files/authorization'
@@ -149,30 +149,18 @@ export const GET = withRouteHandler(
149149
}
150150
let mdContent = mdBuffer.toString('utf-8')
151151

152-
const imageIds = extractEmbeddedImageIds(mdContent)
152+
// Ids only: a serve-URL embed names a storage key, which the bundler has no id to rewrite the
153+
// markdown against, so those images stay pointed at their original URL.
154+
const { ids: imageIds } = extractEmbeddedFileRefs(mdContent)
153155

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

156-
if (imageIds.length === 0) {
157-
const mdName = safeFilename(record.originalName)
158-
const mdBytes = Buffer.from(mdContent, 'utf-8')
159-
auditExport('markdown', 0)
160-
return new NextResponse(new Uint8Array(mdBytes), {
161-
status: 200,
162-
headers: {
163-
'Content-Type': 'text/markdown; charset=utf-8',
164-
'Content-Disposition': `attachment; ${encodeFilenameForHeader(mdName)}`,
165-
'Content-Length': String(mdBytes.length),
166-
},
167-
})
168-
}
169-
170158
// Metadata first: declared sizes bound the download before a byte is read, and the
171159
// authorization check costs nothing to run here.
172160
const assetTargets = (
173161
await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => {
174162
try {
175-
const imgRecord = await getFileMetadataById(imageId)
163+
const imgRecord = await getFileMetadataById(storedFileId(imageId))
176164
if (!imgRecord) return null
177165
if (!(await verifyFileAccess(imgRecord.key, userId))) return null
178166
return { imageId, record: imgRecord }
@@ -234,6 +222,21 @@ export const GET = withRouteHandler(
234222
assetMap.set(imageId, { filename, buffer })
235223
}
236224

225+
// Format follows what was bundled, not what was referenced: an embed can point at a file that is
226+
// missing, unreadable, or oversized, and an empty `assets/` zip is a worse answer than the
227+
// document itself. `mdContent` is still unrewritten here, so `mdBuffer` holds exactly its bytes.
228+
if (assetMap.size === 0) {
229+
auditExport('markdown', 0)
230+
return new NextResponse(new Uint8Array(mdBuffer), {
231+
status: 200,
232+
headers: {
233+
'Content-Type': 'text/markdown; charset=utf-8',
234+
'Content-Disposition': `attachment; ${encodeFilenameForHeader(safeFilename(record.originalName))}`,
235+
'Content-Length': String(mdBuffer.length),
236+
},
237+
})
238+
}
239+
237240
for (const [imageId, asset] of assetMap) {
238241
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
239242
const replacement = `./assets/${asset.filename}`

apps/sim/app/api/files/public/[token]/inline/route.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,13 @@ import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
55
import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares'
66
import { parseRequest } from '@/lib/api/server'
7-
import {
8-
extractEmbeddedImageIds,
9-
extractEmbeddedImageKeys,
10-
} from '@/lib/copilot/tools/server/files/embedded-image-refs'
117
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
128
import { generateRequestId } from '@/lib/core/utils/request'
139
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1410
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
1511
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
1612
import { downloadFile } from '@/lib/uploads/core/storage-service'
13+
import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
1714
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
1815
import { serveInlineImage } from '@/app/api/files/serve-inline-image'
1916
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
@@ -29,8 +26,9 @@ const logger = createLogger('PublicInlineFileAPI')
2926
* instead of broken icons. The share grants the document bytes; this route extends that grant to the
3027
* document's referenced images only, behind three gates that together hold the security boundary:
3128
*
32-
* 1. Referenced-by-doc — the requested key/id must appear in the shared document's current bytes. The
33-
* token is a capability for the document and its embeds, never an arbitrary workspace file.
29+
* 1. Referenced-by-doc — the requested key/id must be embedded as an image by the shared document's
30+
* current bytes. The token is a capability for the document and its embeds, never an arbitrary
31+
* workspace file, and never one the document merely links to or mentions in prose.
3432
* 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace
3533
* ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author
3634
* can write but must never resolve) from loading.
@@ -74,9 +72,8 @@ export const GET = withRouteHandler(
7472

7573
// Referenced-by-doc gate: the share grants exactly the images the document embeds.
7674
const docText = (await downloadFile({ key: doc.key, context: 'workspace' })).toString('utf-8')
77-
const referenced = ref.fileId
78-
? extractEmbeddedImageIds(docText).includes(ref.fileId)
79-
: extractEmbeddedImageKeys(docText).includes(ref.key as string)
75+
const { keys, ids } = extractEmbeddedFileRefs(docText)
76+
const referenced = ref.fileId ? ids.includes(ref.fileId) : keys.includes(ref.key as string)
8077
if (!referenced) {
8178
throw new FileNotFoundError('Not found')
8279
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
import { createMarkdownEditorExtensions } from './editor-extensions'
1212
import {
1313
extractImageFiles,
14-
extractImgSrcs,
1514
findHostedImageAttrs,
1615
hasHostedImageHtml,
1716
htmlReferencesSrc,
@@ -151,19 +150,6 @@ describe('hasHostedImageHtml', () => {
151150
})
152151
})
153152

154-
describe('extractImgSrcs', () => {
155-
it('extracts every img src in document order, including duplicates', () => {
156-
expect(
157-
extractImgSrcs('<img src="/a.png"><p>text</p><img src="/b.png"><img src="/a.png">')
158-
).toEqual(['/a.png', '/b.png', '/a.png'])
159-
})
160-
161-
it('returns an empty array for html with no img', () => {
162-
expect(extractImgSrcs('<p>hello</p>')).toEqual([])
163-
expect(extractImgSrcs('')).toEqual([])
164-
})
165-
})
166-
167153
describe('shouldSkipFileUpload (shared by paste and drop)', () => {
168154
const isHosted = (src: string) => src.startsWith('/api/files/view/')
169155
const hostedHtml = '<img src="/api/files/view/wf_abc">'

0 commit comments

Comments
 (0)