diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts
index 069e1fc30a9..8b6e7b53d24 100644
--- a/apps/sim/app/api/files/export/[id]/route.test.ts
+++ b/apps/sim/app/api/files/export/[id]/route.test.ts
@@ -11,23 +11,29 @@ const {
mockGetFileMetadataById,
mockVerifyFileAccess,
mockDownloadFile,
- mockExtractEmbeddedImageIds,
+ mockExtractEmbeddedFileRefs,
} = vi.hoisted(() => ({
mockCheckAuth: vi.fn(),
mockGetFileMetadataById: vi.fn(),
mockVerifyFileAccess: vi.fn(),
mockDownloadFile: vi.fn(),
- mockExtractEmbeddedImageIds: vi.fn(),
+ mockExtractEmbeddedFileRefs: vi.fn(),
}))
+/** `embedded-image-refs.test.ts` covers the grammar itself. */
+function embeds(...ids: string[]) {
+ mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids })
+}
+
vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth }))
vi.mock('@/lib/uploads/server/metadata', () => ({
getFileMetadataById: mockGetFileMetadataById,
}))
vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess }))
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
-vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({
- extractEmbeddedImageIds: mockExtractEmbeddedImageIds,
+vi.mock('@/lib/uploads/server/embedded-image-refs', () => ({
+ extractEmbeddedFileRefs: mockExtractEmbeddedFileRefs,
+ storedFileId: (spelledId: string) => decodeURIComponent(spelledId),
}))
vi.mock('@sim/audit', () => ({
recordAudit: vi.fn(),
@@ -58,43 +64,35 @@ function assetRecord(id: string, size: number) {
}
}
-describe('markdown export bundling', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' })
- mockVerifyFileAccess.mockResolvedValue(true)
- mockGetFileMetadataById.mockImplementation(async (id: string) =>
- id === DOC_ID
- ? {
- id: DOC_ID,
- key: 'workspace/ws-1/doc.md',
- originalName: 'doc.md',
- contentType: 'text/markdown',
- context: 'workspace',
- size: 1024,
- workspaceId: 'ws-1',
- }
- : assetRecord(id, 1 * MB)
- )
- mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n'))
- mockExtractEmbeddedImageIds.mockReturnValue([])
- })
+const DOC_RECORD = {
+ id: DOC_ID,
+ key: 'workspace/ws-1/doc.md',
+ originalName: 'doc.md',
+ contentType: 'text/markdown',
+ context: 'workspace',
+ size: 1024,
+ workspaceId: 'ws-1',
+}
+
+function assetsResolveTo(assetFor: (id: string) => unknown) {
+ mockGetFileMetadataById.mockImplementation(async (id: string) =>
+ id === DOC_ID ? DOC_RECORD : assetFor(id)
+ )
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' })
+ mockVerifyFileAccess.mockResolvedValue(true)
+ assetsResolveTo((id) => assetRecord(id, 1 * MB))
+ mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n'))
+ embeds()
+})
+describe('markdown export bundling', () => {
it('rejects on declared asset bytes before downloading any of them', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c'])
- mockGetFileMetadataById.mockImplementation(async (id: string) =>
- id === DOC_ID
- ? {
- id: DOC_ID,
- key: 'workspace/ws-1/doc.md',
- originalName: 'doc.md',
- contentType: 'text/markdown',
- context: 'workspace',
- size: 1024,
- workspaceId: 'ws-1',
- }
- : assetRecord(id, 100 * MB)
- )
+ embeds('a', 'b', 'c')
+ assetsResolveTo((id) => assetRecord(id, 100 * MB))
const response = await GET(request(), context)
@@ -106,7 +104,7 @@ describe('markdown export bundling', () => {
it('counts the document body against the export limit, not just its assets', async () => {
// Assets alone sit under the cap; the body is what carries the bundle over it.
- mockExtractEmbeddedImageIds.mockReturnValue(['a'])
+ embeds('a')
mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB))
const response = await GET(request(), context)
@@ -116,7 +114,7 @@ describe('markdown export bundling', () => {
})
it('caps the document body read rather than loading it unbounded', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue([])
+ embeds()
await GET(request(), context)
@@ -125,7 +123,7 @@ describe('markdown export bundling', () => {
})
it('reports an oversized body as a size rejection, not a server error', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue([])
+ embeds()
mockDownloadFile.mockRejectedValue(
new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 })
)
@@ -138,7 +136,7 @@ describe('markdown export bundling', () => {
})
it('caps each asset download rather than trusting its declared size', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['a'])
+ embeds('a')
await GET(request(), context)
@@ -149,7 +147,7 @@ describe('markdown export bundling', () => {
})
it('drops an unreadable asset instead of failing the whole export', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad'])
+ embeds('good', 'bad')
mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {
if (key.endsWith('doc.md')) return Buffer.from('# Doc\n\n')
if (key.endsWith('bad')) throw new Error('storage down')
@@ -164,8 +162,31 @@ describe('markdown export bundling', () => {
expect(zip.file('assets/bad.png')).toBeNull()
})
+ /**
+ * The two id representations have to stay distinct: metadata resolves by the stored id, while the
+ * rewrite finds the embed by the spelling the document used. Collapsing them either drops the
+ * asset or bundles it behind a link still pointing at the API.
+ */
+ it('resolves and rewrites an embed whose id is percent-encoded in the document', async () => {
+ embeds('wf%5Fa')
+ assetsResolveTo((id) => (id === 'wf_a' ? assetRecord(id, 1 * MB) : null))
+ mockDownloadFile.mockImplementation(async ({ key }: { key: string }) =>
+ key.endsWith('doc.md')
+ ? Buffer.from('# Doc\n\n')
+ : Buffer.from('png-bytes')
+ )
+
+ const response = await GET(request(), context)
+
+ const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
+ expect(zip.file('assets/wf_a.png')).not.toBeNull()
+ const md = await zip.file('doc.md')?.async('string')
+ expect(md).toContain('./assets/wf_a.png')
+ expect(md).not.toContain('/api/files/view/')
+ })
+
it('skips an asset the caller cannot read', async () => {
- mockExtractEmbeddedImageIds.mockReturnValue(['secret'])
+ embeds('secret')
mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret'))
const response = await GET(request(), context)
@@ -177,3 +198,47 @@ describe('markdown export bundling', () => {
)
})
})
+
+describe('markdown export format', () => {
+ async function expectPlainMarkdown(response: Response) {
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
+ expect(response.headers.get('Content-Disposition')).toContain('doc.md')
+ expect(await response.text()).toBe('# Doc\n')
+ }
+
+ it('returns the document itself when it embeds nothing', async () => {
+ await expectPlainMarkdown(await GET(request(), context))
+ })
+
+ /**
+ * The reported bug: a document that references files which no longer resolve downloaded as a zip
+ * whose `assets/` folder was empty. The format follows what was bundled, not what was referenced.
+ */
+ it('returns the document itself when no embed resolves to a file', async () => {
+ embeds('gone', 'also-gone')
+ assetsResolveTo(() => null)
+
+ await expectPlainMarkdown(await GET(request(), context))
+ })
+
+ it('returns the document itself when every embed fails to download', async () => {
+ embeds('a')
+ mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => {
+ if (key.endsWith('doc.md')) return Buffer.from('# Doc\n')
+ throw new Error('storage down')
+ })
+
+ await expectPlainMarkdown(await GET(request(), context))
+ })
+
+ it('bundles a zip once at least one embed resolves', async () => {
+ embeds('a')
+
+ const response = await GET(request(), context)
+
+ expect(response.headers.get('Content-Type')).toBe('application/zip')
+ const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer()))
+ expect(zip.file('assets/a.png')).not.toBeNull()
+ })
+})
diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts
index 0e578ada87b..ceb2d6d118e 100644
--- a/apps/sim/app/api/files/export/[id]/route.ts
+++ b/apps/sim/app/api/files/export/[id]/route.ts
@@ -8,7 +8,6 @@ import { NextResponse } from 'next/server'
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -16,6 +15,7 @@ import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
import { getServeStoragePrefix } from '@/lib/uploads/config'
import { downloadFile } from '@/lib/uploads/core/storage-service'
+import { extractEmbeddedFileRefs, storedFileId } from '@/lib/uploads/server/embedded-image-refs'
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
@@ -149,30 +149,18 @@ export const GET = withRouteHandler(
}
let mdContent = mdBuffer.toString('utf-8')
- const imageIds = extractEmbeddedImageIds(mdContent)
+ // Ids only: a serve-URL embed names a storage key, which the bundler has no id to rewrite the
+ // markdown against, so those images stay pointed at their original URL.
+ const { ids: imageIds } = extractEmbeddedFileRefs(mdContent)
logger.info('Exporting markdown', { id, imageCount: imageIds.length })
- if (imageIds.length === 0) {
- const mdName = safeFilename(record.originalName)
- const mdBytes = Buffer.from(mdContent, 'utf-8')
- auditExport('markdown', 0)
- return new NextResponse(new Uint8Array(mdBytes), {
- status: 200,
- headers: {
- 'Content-Type': 'text/markdown; charset=utf-8',
- 'Content-Disposition': `attachment; ${encodeFilenameForHeader(mdName)}`,
- 'Content-Length': String(mdBytes.length),
- },
- })
- }
-
// Metadata first: declared sizes bound the download before a byte is read, and the
// authorization check costs nothing to run here.
const assetTargets = (
await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => {
try {
- const imgRecord = await getFileMetadataById(imageId)
+ const imgRecord = await getFileMetadataById(storedFileId(imageId))
if (!imgRecord) return null
if (!(await verifyFileAccess(imgRecord.key, userId))) return null
return { imageId, record: imgRecord }
@@ -234,6 +222,21 @@ export const GET = withRouteHandler(
assetMap.set(imageId, { filename, buffer })
}
+ // Format follows what was bundled, not what was referenced: an embed can point at a file that is
+ // missing, unreadable, or oversized, and an empty `assets/` zip is a worse answer than the
+ // document itself. `mdContent` is still unrewritten here, so `mdBuffer` holds exactly its bytes.
+ if (assetMap.size === 0) {
+ auditExport('markdown', 0)
+ return new NextResponse(new Uint8Array(mdBuffer), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8',
+ 'Content-Disposition': `attachment; ${encodeFilenameForHeader(safeFilename(record.originalName))}`,
+ 'Content-Length': String(mdBuffer.length),
+ },
+ })
+ }
+
for (const [imageId, asset] of assetMap) {
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const replacement = `./assets/${asset.filename}`
diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts
index a777b953ba5..85405a74712 100644
--- a/apps/sim/app/api/files/public/[token]/inline/route.ts
+++ b/apps/sim/app/api/files/public/[token]/inline/route.ts
@@ -4,16 +4,13 @@ import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
-import {
- extractEmbeddedImageIds,
- extractEmbeddedImageKeys,
-} from '@/lib/copilot/tools/server/files/embedded-image-refs'
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
+import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
import { serveInlineImage } from '@/app/api/files/serve-inline-image'
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
@@ -29,8 +26,9 @@ const logger = createLogger('PublicInlineFileAPI')
* instead of broken icons. The share grants the document bytes; this route extends that grant to the
* document's referenced images only, behind three gates that together hold the security boundary:
*
- * 1. Referenced-by-doc — the requested key/id must appear in the shared document's current bytes. The
- * token is a capability for the document and its embeds, never an arbitrary workspace file.
+ * 1. Referenced-by-doc — the requested key/id must be embedded as an image by the shared document's
+ * current bytes. The token is a capability for the document and its embeds, never an arbitrary
+ * workspace file, and never one the document merely links to or mentions in prose.
* 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace
* ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author
* can write but must never resolve) from loading.
@@ -74,9 +72,8 @@ export const GET = withRouteHandler(
// Referenced-by-doc gate: the share grants exactly the images the document embeds.
const docText = (await downloadFile({ key: doc.key, context: 'workspace' })).toString('utf-8')
- const referenced = ref.fileId
- ? extractEmbeddedImageIds(docText).includes(ref.fileId)
- : extractEmbeddedImageKeys(docText).includes(ref.key as string)
+ const { keys, ids } = extractEmbeddedFileRefs(docText)
+ const referenced = ref.fileId ? ids.includes(ref.fileId) : keys.includes(ref.key as string)
if (!referenced) {
throw new FileNotFoundError('Not found')
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
index fff5f035a81..15f4f1fbd28 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts
@@ -11,7 +11,6 @@ import {
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
extractImageFiles,
- extractImgSrcs,
findHostedImageAttrs,
hasHostedImageHtml,
htmlReferencesSrc,
@@ -151,19 +150,6 @@ describe('hasHostedImageHtml', () => {
})
})
-describe('extractImgSrcs', () => {
- it('extracts every img src in document order, including duplicates', () => {
- expect(
- extractImgSrcs('
text

')
- ).toEqual(['/a.png', '/b.png', '/a.png'])
- })
-
- it('returns an empty array for html with no img', () => {
- expect(extractImgSrcs('hello
')).toEqual([]) - expect(extractImgSrcs('')).toEqual([]) - }) -}) - describe('shouldSkipFileUpload (shared by paste and drop)', () => { const isHosted = (src: string) => src.startsWith('/api/files/view/') const hostedHtml = '', + ].join('\n') + expect(extractEmbeddedFileRefs(content)).toEqual({ keys: [], ids: [] }) + }) + + it('caps total references (keys + ids) at 50 combined', () => { + const images = [ + ...Array.from({ length: 40 }, (_, i) => ``), + ...Array.from( + { length: 40 }, + (_, i) => `})` + ), + ] + const { keys, ids } = extractEmbeddedFileRefs(images.join('\n\n')) + expect(keys.length + ids.length).toBe(50) + }) +}) diff --git a/apps/sim/lib/uploads/server/embedded-image-refs.ts b/apps/sim/lib/uploads/server/embedded-image-refs.ts new file mode 100644 index 00000000000..fb3574e20df --- /dev/null +++ b/apps/sim/lib/uploads/server/embedded-image-refs.ts @@ -0,0 +1,98 @@ +/** + * Finds the workspace files a markdown document embeds as images. + * + * Lives under `server/` rather than beside its pure sibling in `lib/uploads/utils` so that `marked` + * stays out of the client bundles that import the single-`src` grammar. + */ +import { Marked, type Token } from 'marked' +import { extractEmbeddedFileRef, extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref' + +/** Hard cap on embedded images resolved from one document — bounds export bundles and the share cascade. */ +export const MAX_EMBEDDED_IMAGES = 50 + +/** + * The stored id behind the spelling a document used. {@link extractEmbeddedFileRefs} returns ids + * exactly as the document writes them, because that is what the export bundler searches for when it + * rewrites an embed — but storage is keyed by the decoded id. Every consumer that resolves one of + * these ids goes through here, so a document's spelling and the stored id stay distinct without + * either side re-deriving the other. + * + * Only for ids read out of document text. Ids arriving as request input are already constrained to + * the plain id charset by their route contract, so they need no decoding. + */ +export function storedFileId(spelledId: string): string { + try { + return decodeURIComponent(spelledId) + } catch { + return spelledId + } +} + +/** + * A parser of this module's own, not the `marked` singleton: the public share's referenced-by-doc + * gate authorizes against what this returns, and a global `marked.use()` elsewhere in the process + * must not be able to redefine an authorization boundary. + */ +const markdown = new Marked() + +/** Children hang off `tokens`, except on tables (per cell) and lists (per item). */ +function childrenOf(token: Token): Token[] { + if (token.type === 'table') { + return [...token.header, ...token.rows.flat()].flatMap((cell) => cell.tokens) + } + if (token.type === 'list') return token.items + return 'tokens' in token && token.tokens ? token.tokens : [] +} + +/** + * The de-duplicated workspace keys and file ids `content` embeds **as images**, bounded to + * {@link MAX_EMBEDDED_IMAGES} references combined. Covers markdown images (``, including + * the reference form the lexer resolves) and `
`/`