Skip to content

Commit c76fc77

Browse files
committed
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 f346f5c commit c76fc77

5 files changed

Lines changed: 41 additions & 17 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFi
3333
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
3434
vi.mock('@/lib/uploads/server/embedded-image-refs', () => ({
3535
extractEmbeddedFileRefs: mockExtractEmbeddedFileRefs,
36+
storedFileId: (spelledId: string) => decodeURIComponent(spelledId),
3637
}))
3738
vi.mock('@sim/audit', () => ({
3839
recordAudit: vi.fn(),

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

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { captureServerEvent } from '@/lib/posthog/server'
1515
import type { StorageContext } from '@/lib/uploads/config'
1616
import { getServeStoragePrefix } from '@/lib/uploads/config'
1717
import { downloadFile } from '@/lib/uploads/core/storage-service'
18-
import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
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'
@@ -51,19 +51,6 @@ 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-
6754
function deduplicatedFilename(preferred: string, existing: Set<string>, imageId: string): string {
6855
if (!existing.has(preferred)) return preferred
6956
const ext = path.extname(preferred)
@@ -173,7 +160,7 @@ export const GET = withRouteHandler(
173160
const assetTargets = (
174161
await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => {
175162
try {
176-
const imgRecord = await getFileMetadataById(storedId(imageId))
163+
const imgRecord = await getFileMetadataById(storedFileId(imageId))
177164
if (!imgRecord) return null
178165
if (!(await verifyFileAccess(imgRecord.key, userId))) return null
179166
return { imageId, record: imgRecord }

apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,19 @@ describe('findUnembeddableImageRefs', () => {
4444
expect(await findUnembeddableImageRefs(content, WORKSPACE_ID)).toEqual([])
4545
expect(mockGetFileMetadataById).not.toHaveBeenCalled()
4646
})
47+
48+
/**
49+
* The export bundler resolves an embed by its stored id, so reporting the same embed as one that
50+
* will not survive an export would contradict what the export actually does with it.
51+
*/
52+
it('resolves a percent-encoded embed by its stored id, like the export does', async () => {
53+
mockGetFileMetadataById.mockImplementation(async (id: string) =>
54+
id === 'wf_abc' ? { context: 'workspace', workspaceId: WORKSPACE_ID } : null
55+
)
56+
57+
expect(await findUnembeddableImageRefs('![a](/api/files/view/wf%5Fabc)', WORKSPACE_ID)).toEqual(
58+
[]
59+
)
60+
expect(mockGetFileMetadataById).toHaveBeenCalledWith('wf_abc')
61+
})
4762
})

apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
1+
import { extractEmbeddedFileRefs, storedFileId } from '@/lib/uploads/server/embedded-image-refs'
22
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
33

44
/**
@@ -9,6 +9,9 @@ import { getFileMetadataById } from '@/lib/uploads/server/metadata'
99
* flagged by id alone, without disclosing the referenced file's real context or owning workspace, so
1010
* the result can't be used to probe files outside this workspace. Best-effort and never throws, so a
1111
* content write is never blocked by this validation.
12+
*
13+
* Resolved through {@link storedFileId}, like the export bundler: an embed the export would resolve
14+
* and bundle must not be reported here as one that will not survive it.
1215
*/
1316
export async function findUnembeddableImageRefs(
1417
content: string,
@@ -19,7 +22,7 @@ export async function findUnembeddableImageRefs(
1922
const checked = await Promise.all(
2023
ids.map(async (id): Promise<string | null> => {
2124
try {
22-
const record = await getFileMetadataById(id)
25+
const record = await getFileMetadataById(storedFileId(id))
2326
const embeddable = record?.context === 'workspace' && record.workspaceId === workspaceId
2427
return embeddable ? null : id
2528
} catch {

apps/sim/lib/uploads/server/embedded-image-refs.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ import { extractEmbeddedFileRef, extractImgSrcs } from '@/lib/uploads/utils/embe
1010
/** Hard cap on embedded images resolved from one document — bounds export bundles and the share cascade. */
1111
export const MAX_EMBEDDED_IMAGES = 50
1212

13+
/**
14+
* The stored id behind the spelling a document used. {@link extractEmbeddedFileRefs} returns ids
15+
* exactly as the document writes them, because that is what the export bundler searches for when it
16+
* rewrites an embed — but storage is keyed by the decoded id. Every consumer that resolves one of
17+
* these ids goes through here, so a document's spelling and the stored id stay distinct without
18+
* either side re-deriving the other.
19+
*
20+
* Only for ids read out of document text. Ids arriving as request input are already constrained to
21+
* the plain id charset by their route contract, so they need no decoding.
22+
*/
23+
export function storedFileId(spelledId: string): string {
24+
try {
25+
return decodeURIComponent(spelledId)
26+
} catch {
27+
return spelledId
28+
}
29+
}
30+
1331
/**
1432
* A parser of this module's own, not the `marked` singleton: the public share's referenced-by-doc
1533
* gate authorizes against what this returns, and a global `marked.use()` elsewhere in the process

0 commit comments

Comments
 (0)