Skip to content

Commit cff7a49

Browse files
feat(file): workspace-scoped inline images + public-share cascade (#5203)
* feat(file): workspace-scoped inline images + public-share cascade Embedded markdown images now resolve only within the document's workspace, and public file shares cascade to the images the shared document embeds. - New /api/workspaces/[id]/files/inline (in-app, workspace-scoped) and /api/files/public/[token]/inline (public cascade) routes; the public one serves an embed only when it is referenced-by-doc, same-workspace, and passes a magic-byte image sniff - Embed srcs (serve-key and view-id forms) rewrite through one scoped inline route; one shared isomorphic parser owns the embed grammar for both the frontend renderer and the server doc scan - Accept wf_ file ids on the view/export routes (were 400ing on .uuid()) * feat(file): add Image command to the markdown editor slash menu - New /Image slash command uploads an image via a file picker and inserts it at the caret (same upload+insert path as paste/drop) - Inserted src is the workspace serve URL, so it renders in-app and cascades to public shares like any other embed - Per-editor handler wired through slash-command storage (the extension set is a shared singleton); only active when the editor is editable * fix(file): export rewrites all embed forms; cap embedded refs combined Addresses PR review: - Markdown export now rewrites the in-app `/workspace/<ws>/files/<id>` embed form too (not just `/api/files/view/<id>`), so a bundled asset never leaves a broken link in an offline export (Bugbot) - extractEmbeddedFileRefs bounds total references (keys + ids) to 50 combined rather than 50 each, matching MAX_EMBEDDED_IMAGES intent
1 parent ae4bc05 commit cff7a49

28 files changed

Lines changed: 1073 additions & 69 deletions

File tree

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

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { NextResponse } from 'next/server'
77
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
88
import { parseRequest } from '@/lib/api/server'
99
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
10+
import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1112
import type { StorageContext } from '@/lib/uploads/config'
1213
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
@@ -19,9 +20,6 @@ const logger = createLogger('FilesExportAPI')
1920

2021
const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
2122
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
22-
const VIEW_URL_RE =
23-
/\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
24-
const MAX_EMBEDDED_IMAGES = 50
2523

2624
function isMarkdown(originalName: string, contentType: string): boolean {
2725
if (MARKDOWN_MIME_TYPES.has(contentType)) return true
@@ -82,10 +80,7 @@ export const GET = withRouteHandler(
8280
})
8381
let mdContent = mdBuffer.toString('utf-8')
8482

85-
const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))].slice(
86-
0,
87-
MAX_EMBEDDED_IMAGES
88-
)
83+
const imageIds = extractEmbeddedImageIds(mdContent)
8984

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

@@ -139,10 +134,11 @@ export const GET = withRouteHandler(
139134
for (const [imageId, asset] of assetMap) {
140135
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
141136
const replacement = `./assets/${asset.filename}`
142-
mdContent = mdContent.replace(
143-
new RegExp(`/api/files/view/${escapedId}`, 'g'),
144-
() => replacement
145-
)
137+
// Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app
138+
// `/workspace/<ws>/files/<id>` path — so a bundled asset never leaves a broken link in the export.
139+
mdContent = mdContent
140+
.replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement)
141+
.replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement)
146142
}
147143

148144
const zip = new JSZip()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockResolveShare, mockRateLimit, mockValidateAuth, mockDownloadFile, mockResolveImage } =
8+
vi.hoisted(() => ({
9+
mockResolveShare: vi.fn(),
10+
mockRateLimit: vi.fn(),
11+
mockValidateAuth: vi.fn(),
12+
mockDownloadFile: vi.fn(),
13+
mockResolveImage: vi.fn(),
14+
}))
15+
16+
vi.mock('@/lib/public-shares/share-manager', () => ({
17+
resolveActiveShareByToken: mockResolveShare,
18+
}))
19+
vi.mock('@/lib/public-shares/rate-limit', () => ({ enforcePublicFileRateLimit: mockRateLimit }))
20+
vi.mock('@/lib/core/security/deployment-auth', () => ({ validateDeploymentAuth: mockValidateAuth }))
21+
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
22+
vi.mock('@/lib/uploads/server/inline-image', () => ({
23+
resolveWorkspaceInlineImage: mockResolveImage,
24+
}))
25+
26+
import { GET } from '@/app/api/files/public/[token]/inline/route'
27+
28+
const TOKEN = 'tok_share_123456'
29+
const DOC_KEY = 'workspace/ws-1/doc.md'
30+
const IMG_KEY = 'workspace/ws-1/photo.png'
31+
const FILE_ID = 'wf_YwDXi8eWOkTxn0sbgChlB'
32+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
33+
34+
const params = { params: Promise.resolve({ token: TOKEN }) }
35+
const req = (q: string) => new NextRequest(`http://localhost/api/files/public/${TOKEN}/inline?${q}`)
36+
37+
const share = {
38+
share: { id: 'sh_1', token: TOKEN, authType: 'public' },
39+
file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' },
40+
workspaceName: 'Acme',
41+
ownerName: 'Jane',
42+
}
43+
44+
/** doc bytes embed the image via the view form; image bytes are a real PNG */
45+
function downloadByKey(docContent = `![a](/api/files/view/${FILE_ID})`) {
46+
return ({ key }: { key: string }) =>
47+
Promise.resolve(key === DOC_KEY ? Buffer.from(docContent, 'utf-8') : PNG)
48+
}
49+
50+
describe('GET /api/files/public/[token]/inline', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
mockRateLimit.mockResolvedValue(null)
54+
mockResolveShare.mockResolvedValue(share)
55+
mockValidateAuth.mockResolvedValue({ authorized: true })
56+
mockResolveImage.mockResolvedValue({
57+
key: IMG_KEY,
58+
contentType: 'image/png',
59+
filename: 'photo.png',
60+
})
61+
mockDownloadFile.mockImplementation(downloadByKey())
62+
})
63+
64+
it('serves a same-workspace image referenced by the doc, typed from its bytes', async () => {
65+
const res = await GET(req(`fileId=${FILE_ID}`), params)
66+
expect(res.status).toBe(200)
67+
expect(res.headers.get('content-type')).toBe('image/png')
68+
})
69+
70+
it('serves a key-referenced image', async () => {
71+
mockDownloadFile.mockImplementation(
72+
downloadByKey(`![a](/api/files/serve/${encodeURIComponent(IMG_KEY)}?context=workspace)`)
73+
)
74+
const res = await GET(req(`key=${encodeURIComponent(IMG_KEY)}`), params)
75+
expect(res.status).toBe(200)
76+
})
77+
78+
it('404s when the reference is not embedded in the shared document', async () => {
79+
mockDownloadFile.mockImplementation(downloadByKey('no images here'))
80+
const res = await GET(req(`fileId=${FILE_ID}`), params)
81+
expect(res.status).toBe(404)
82+
expect(mockResolveImage).not.toHaveBeenCalled()
83+
})
84+
85+
it('404s when the referenced file is not in the document workspace', async () => {
86+
mockResolveImage.mockResolvedValue(null)
87+
const res = await GET(req(`fileId=${FILE_ID}`), params)
88+
expect(res.status).toBe(404)
89+
})
90+
91+
it('404s when the bytes are not a renderable image', async () => {
92+
mockDownloadFile.mockImplementation(({ key }: { key: string }) =>
93+
Promise.resolve(
94+
key === DOC_KEY
95+
? Buffer.from(`![a](/api/files/view/${FILE_ID})`, 'utf-8')
96+
: Buffer.from('<svg/>', 'utf-8')
97+
)
98+
)
99+
const res = await GET(req(`fileId=${FILE_ID}`), params)
100+
expect(res.status).toBe(404)
101+
})
102+
103+
it('401s and never reads storage when the share is unauthorized', async () => {
104+
mockValidateAuth.mockResolvedValue({ authorized: false, error: 'auth_required_password' })
105+
const res = await GET(req(`fileId=${FILE_ID}`), params)
106+
expect(res.status).toBe(401)
107+
expect(mockDownloadFile).not.toHaveBeenCalled()
108+
})
109+
110+
it('404s for an unknown or inactive token', async () => {
111+
mockResolveShare.mockResolvedValue(null)
112+
const res = await GET(req(`fileId=${FILE_ID}`), params)
113+
expect(res.status).toBe(404)
114+
expect(mockDownloadFile).not.toHaveBeenCalled()
115+
})
116+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { NextRequest } from 'next/server'
3+
import { NextResponse } from 'next/server'
4+
import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares'
5+
import { parseRequest } from '@/lib/api/server'
6+
import {
7+
extractEmbeddedImageIds,
8+
extractEmbeddedImageKeys,
9+
} from '@/lib/copilot/tools/server/files/embedded-image-refs'
10+
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
11+
import { generateRequestId } from '@/lib/core/utils/request'
12+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
14+
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
15+
import { downloadFile } from '@/lib/uploads/core/storage-service'
16+
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
17+
import { serveInlineImage } from '@/app/api/files/serve-inline-image'
18+
import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils'
19+
20+
export const dynamic = 'force-dynamic'
21+
22+
const logger = createLogger('PublicInlineFileAPI')
23+
24+
/**
25+
* GET /api/files/public/[token]/inline?key=<cloudKey>|fileId=<id>
26+
*
27+
* Cascades a markdown document's public share to the images it embeds, so a logged-out viewer sees them
28+
* instead of broken icons. The share grants the document bytes; this route extends that grant to the
29+
* document's referenced images only, behind three gates that together hold the security boundary:
30+
*
31+
* 1. Referenced-by-doc — the requested key/id must appear in the shared document's current bytes. The
32+
* token is a capability for the document and its embeds, never an arbitrary workspace file.
33+
* 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace
34+
* ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author
35+
* can write but must never resolve) from loading.
36+
* 3. Content-truth — the served content type is sniffed from the bytes, not the client-declared type,
37+
* and only genuine raster images are served. A file spoofing `image/png` while holding HTML/SVG is
38+
* refused rather than rendered inline.
39+
*/
40+
export const GET = withRouteHandler(
41+
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
42+
const requestId = generateRequestId()
43+
44+
try {
45+
const limited = await enforcePublicFileRateLimit(request, 'content')
46+
if (limited) return limited
47+
48+
const parsed = await parseRequest(getPublicInlineFileContract, request, context)
49+
if (!parsed.success) return parsed.response
50+
const { token } = parsed.data.params
51+
const ref = parsed.data.query
52+
53+
const resolved = await resolveActiveShareByToken(token)
54+
if (!resolved) {
55+
throw new FileNotFoundError('Not found')
56+
}
57+
58+
const auth = await validateDeploymentAuth(
59+
requestId,
60+
resolved.share,
61+
request,
62+
undefined,
63+
'file'
64+
)
65+
if (!auth.authorized) {
66+
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
67+
}
68+
69+
const { file: doc } = resolved
70+
if (!doc.workspaceId) {
71+
throw new FileNotFoundError('Not found')
72+
}
73+
74+
// Referenced-by-doc gate: the share grants exactly the images the document embeds.
75+
const docText = (await downloadFile({ key: doc.key, context: 'workspace' })).toString('utf-8')
76+
const referenced = ref.fileId
77+
? extractEmbeddedImageIds(docText).includes(ref.fileId)
78+
: extractEmbeddedImageKeys(docText).includes(ref.key as string)
79+
if (!referenced) {
80+
throw new FileNotFoundError('Not found')
81+
}
82+
83+
// Same-workspace gate: resolve scoped to the document's own workspace.
84+
const image = await resolveWorkspaceInlineImage(doc.workspaceId, ref)
85+
if (!image) {
86+
throw new FileNotFoundError('Not found')
87+
}
88+
89+
// Content-truth gate (`sniff`): render only genuine raster image bytes.
90+
return await serveInlineImage(image, { sniff: true })
91+
} catch (error) {
92+
if (error instanceof FileNotFoundError) {
93+
return createErrorResponse(error)
94+
}
95+
logger.error('Error serving public inline image:', error)
96+
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
97+
}
98+
}
99+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { NextResponse } from 'next/server'
3+
import { downloadFile } from '@/lib/uploads/core/storage-service'
4+
import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image'
5+
import { sniffImageContentType } from '@/lib/uploads/utils/validation'
6+
import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
7+
8+
const logger = createLogger('InlineImageServe')
9+
10+
/**
11+
* A shared/edited/deleted file must never serve stale bytes from its fixed inline URL, so every inline
12+
* image revalidates on each request.
13+
*/
14+
const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
15+
16+
/**
17+
* Download and respond with an already-workspace-scoped inline image — the single serving tail for both
18+
* the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the
19+
* served content type is derived from the bytes and non-raster content is refused with 404; otherwise the
20+
* stored content type is served, matching the in-app serve route.
21+
*/
22+
export async function serveInlineImage(
23+
image: ResolvedInlineImage,
24+
{ sniff }: { sniff: boolean }
25+
): Promise<NextResponse> {
26+
const buffer = await downloadFile({ key: image.key, context: 'workspace' })
27+
28+
let contentType = image.contentType
29+
if (sniff) {
30+
const sniffed = sniffImageContentType(buffer)
31+
if (!sniffed) {
32+
logger.warn('Embedded reference is not a renderable image', { key: image.key })
33+
throw new FileNotFoundError('Not found')
34+
}
35+
contentType = sniffed
36+
}
37+
38+
return createFileResponse({
39+
buffer,
40+
contentType,
41+
filename: image.filename,
42+
cacheControl: INLINE_CACHE_CONTROL,
43+
})
44+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({
8+
mockGetSession: vi.fn(),
9+
mockGetPerms: vi.fn(),
10+
mockResolveImage: vi.fn(),
11+
mockDownloadFile: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({
15+
auth: { api: { getSession: vi.fn() } },
16+
getSession: mockGetSession,
17+
}))
18+
vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms }))
19+
vi.mock('@/lib/uploads/server/inline-image', () => ({
20+
resolveWorkspaceInlineImage: mockResolveImage,
21+
}))
22+
vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile }))
23+
24+
import { GET } from '@/app/api/workspaces/[id]/files/inline/route'
25+
26+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
27+
const params = { params: Promise.resolve({ id: 'ws-1' }) }
28+
const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`)
29+
30+
describe('GET /api/workspaces/[id]/files/inline', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
34+
mockGetPerms.mockResolvedValue('read')
35+
mockResolveImage.mockResolvedValue({
36+
key: 'workspace/ws-1/x-photo.png',
37+
contentType: 'image/png',
38+
filename: 'photo.png',
39+
})
40+
mockDownloadFile.mockResolvedValue(PNG)
41+
})
42+
43+
it('serves a workspace-scoped image by fileId', async () => {
44+
const res = await GET(req('fileId=wf_abc'), params)
45+
expect(res.status).toBe(200)
46+
expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' })
47+
})
48+
49+
it('serves a workspace-scoped image by key', async () => {
50+
const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params)
51+
expect(res.status).toBe(200)
52+
})
53+
54+
it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => {
55+
mockResolveImage.mockResolvedValue(null)
56+
const res = await GET(req('fileId=wf_other'), params)
57+
expect(res.status).toBe(404)
58+
})
59+
60+
it('404s without workspace membership, before resolving the file', async () => {
61+
mockGetPerms.mockResolvedValue(null)
62+
const res = await GET(req('fileId=wf_abc'), params)
63+
expect(res.status).toBe(404)
64+
expect(mockResolveImage).not.toHaveBeenCalled()
65+
})
66+
67+
it('401s without a session', async () => {
68+
mockGetSession.mockResolvedValue(null)
69+
const res = await GET(req('fileId=wf_abc'), params)
70+
expect(res.status).toBe(401)
71+
})
72+
73+
it('400s when neither key nor fileId is provided', async () => {
74+
const res = await GET(req(''), params)
75+
expect(res.status).toBe(400)
76+
})
77+
})

0 commit comments

Comments
 (0)