Skip to content

Commit f346f5c

Browse files
committed
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.
1 parent 10bb0e7 commit f346f5c

2 files changed

Lines changed: 37 additions & 1 deletion

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,29 @@ describe('markdown export bundling', () => {
161161
expect(zip.file('assets/bad.png')).toBeNull()
162162
})
163163

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

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ function safeFilename(name: string): string {
5151
.replace(/[\r\n\t]/g, '')
5252
}
5353

54+
/**
55+
* The stored id behind an embed's spelling. Ids arrive spelled as the document writes them, because
56+
* the rewrite below locates each embed by searching for that spelling; metadata lookup needs the
57+
* decoded id instead, so the two representations are kept distinct rather than reconciled.
58+
*/
59+
function storedId(spelledId: string): string {
60+
try {
61+
return decodeURIComponent(spelledId)
62+
} catch {
63+
return spelledId
64+
}
65+
}
66+
5467
function deduplicatedFilename(preferred: string, existing: Set<string>, imageId: string): string {
5568
if (!existing.has(preferred)) return preferred
5669
const ext = path.extname(preferred)
@@ -160,7 +173,7 @@ export const GET = withRouteHandler(
160173
const assetTargets = (
161174
await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => {
162175
try {
163-
const imgRecord = await getFileMetadataById(imageId)
176+
const imgRecord = await getFileMetadataById(storedId(imageId))
164177
if (!imgRecord) return null
165178
if (!(await verifyFileAccess(imgRecord.key, userId))) return null
166179
return { imageId, record: imgRecord }

0 commit comments

Comments
 (0)