From e14499f1284ff4d8015081e5b6f3547e623151f0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 24 Jun 2026 16:09:41 -0700 Subject: [PATCH 1/3] 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()) --- apps/sim/app/api/files/export/[id]/route.ts | 9 +- .../files/public/[token]/inline/route.test.ts | 116 ++++++++++++++++++ .../api/files/public/[token]/inline/route.ts | 99 +++++++++++++++ apps/sim/app/api/files/serve-inline-image.ts | 44 +++++++ .../[id]/files/inline/route.test.ts | 77 ++++++++++++ .../api/workspaces/[id]/files/inline/route.ts | 59 +++++++++ apps/sim/app/f/[token]/public-file-view.tsx | 19 ++- .../components/file-viewer/file-viewer.tsx | 26 +++- .../rich-markdown-editor/image.test.ts | 45 ++++--- .../rich-markdown-editor/image.tsx | 22 +--- apps/sim/hooks/use-file-content-source.tsx | 77 ++++++++++-- apps/sim/lib/api/contracts/primitives.ts | 27 ++++ apps/sim/lib/api/contracts/public-shares.ts | 18 ++- .../sim/lib/api/contracts/storage-transfer.ts | 5 +- apps/sim/lib/api/contracts/workspace-files.ts | 16 +++ .../server/files/embedded-image-refs.test.ts | 43 +++++++ .../tools/server/files/embedded-image-refs.ts | 20 ++- .../lib/uploads/server/inline-image.test.ts | 63 ++++++++++ apps/sim/lib/uploads/server/inline-image.ts | 41 +++++++ .../uploads/utils/embedded-image-ref.test.ts | 55 +++++++++ .../lib/uploads/utils/embedded-image-ref.ts | 74 +++++++++++ apps/sim/lib/uploads/utils/validation.test.ts | 31 +++++ apps/sim/lib/uploads/utils/validation.ts | 28 +++++ scripts/check-api-validation-contracts.ts | 4 +- 24 files changed, 956 insertions(+), 62 deletions(-) create mode 100644 apps/sim/app/api/files/public/[token]/inline/route.test.ts create mode 100644 apps/sim/app/api/files/public/[token]/inline/route.ts create mode 100644 apps/sim/app/api/files/serve-inline-image.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/inline/route.ts create mode 100644 apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts create mode 100644 apps/sim/lib/uploads/server/inline-image.test.ts create mode 100644 apps/sim/lib/uploads/server/inline-image.ts create mode 100644 apps/sim/lib/uploads/utils/embedded-image-ref.test.ts create mode 100644 apps/sim/lib/uploads/utils/embedded-image-ref.ts diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 18c8aafb563..443f50ceb11 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -7,6 +7,7 @@ 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 { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' @@ -19,9 +20,6 @@ const logger = createLogger('FilesExportAPI') const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) -const VIEW_URL_RE = - /\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi -const MAX_EMBEDDED_IMAGES = 50 function isMarkdown(originalName: string, contentType: string): boolean { if (MARKDOWN_MIME_TYPES.has(contentType)) return true @@ -82,10 +80,7 @@ export const GET = withRouteHandler( }) let mdContent = mdBuffer.toString('utf-8') - const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))].slice( - 0, - MAX_EMBEDDED_IMAGES - ) + const imageIds = extractEmbeddedImageIds(mdContent) logger.info('Exporting markdown', { id, imageCount: imageIds.length }) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts new file mode 100644 index 00000000000..3f2b654bda0 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/inline/route.test.ts @@ -0,0 +1,116 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveShare, mockRateLimit, mockValidateAuth, mockDownloadFile, mockResolveImage } = + vi.hoisted(() => ({ + mockResolveShare: vi.fn(), + mockRateLimit: vi.fn(), + mockValidateAuth: vi.fn(), + mockDownloadFile: vi.fn(), + mockResolveImage: vi.fn(), + })) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + resolveActiveShareByToken: mockResolveShare, +})) +vi.mock('@/lib/public-shares/rate-limit', () => ({ enforcePublicFileRateLimit: mockRateLimit })) +vi.mock('@/lib/core/security/deployment-auth', () => ({ validateDeploymentAuth: mockValidateAuth })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) +vi.mock('@/lib/uploads/server/inline-image', () => ({ + resolveWorkspaceInlineImage: mockResolveImage, +})) + +import { GET } from '@/app/api/files/public/[token]/inline/route' + +const TOKEN = 'tok_share_123456' +const DOC_KEY = 'workspace/ws-1/doc.md' +const IMG_KEY = 'workspace/ws-1/photo.png' +const FILE_ID = 'wf_YwDXi8eWOkTxn0sbgChlB' +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) + +const params = { params: Promise.resolve({ token: TOKEN }) } +const req = (q: string) => new NextRequest(`http://localhost/api/files/public/${TOKEN}/inline?${q}`) + +const share = { + share: { id: 'sh_1', token: TOKEN, authType: 'public' }, + file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' }, + workspaceName: 'Acme', + ownerName: 'Jane', +} + +/** doc bytes embed the image via the view form; image bytes are a real PNG */ +function downloadByKey(docContent = `![a](/api/files/view/${FILE_ID})`) { + return ({ key }: { key: string }) => + Promise.resolve(key === DOC_KEY ? Buffer.from(docContent, 'utf-8') : PNG) +} + +describe('GET /api/files/public/[token]/inline', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRateLimit.mockResolvedValue(null) + mockResolveShare.mockResolvedValue(share) + mockValidateAuth.mockResolvedValue({ authorized: true }) + mockResolveImage.mockResolvedValue({ + key: IMG_KEY, + contentType: 'image/png', + filename: 'photo.png', + }) + mockDownloadFile.mockImplementation(downloadByKey()) + }) + + it('serves a same-workspace image referenced by the doc, typed from its bytes', async () => { + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/png') + }) + + it('serves a key-referenced image', async () => { + mockDownloadFile.mockImplementation( + downloadByKey(`![a](/api/files/serve/${encodeURIComponent(IMG_KEY)}?context=workspace)`) + ) + const res = await GET(req(`key=${encodeURIComponent(IMG_KEY)}`), params) + expect(res.status).toBe(200) + }) + + it('404s when the reference is not embedded in the shared document', async () => { + mockDownloadFile.mockImplementation(downloadByKey('no images here')) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + expect(mockResolveImage).not.toHaveBeenCalled() + }) + + it('404s when the referenced file is not in the document workspace', async () => { + mockResolveImage.mockResolvedValue(null) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + }) + + it('404s when the bytes are not a renderable image', async () => { + mockDownloadFile.mockImplementation(({ key }: { key: string }) => + Promise.resolve( + key === DOC_KEY + ? Buffer.from(`![a](/api/files/view/${FILE_ID})`, 'utf-8') + : Buffer.from('', 'utf-8') + ) + ) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + }) + + it('401s and never reads storage when the share is unauthorized', async () => { + mockValidateAuth.mockResolvedValue({ authorized: false, error: 'auth_required_password' }) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(401) + expect(mockDownloadFile).not.toHaveBeenCalled() + }) + + it('404s for an unknown or inactive token', async () => { + mockResolveShare.mockResolvedValue(null) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + expect(mockDownloadFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts new file mode 100644 index 00000000000..87c343a26a8 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +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 { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' +import { serveInlineImage } from '@/app/api/files/serve-inline-image' +import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PublicInlineFileAPI') + +/** + * GET /api/files/public/[token]/inline?key=|fileId= + * + * Cascades a markdown document's public share to the images it embeds, so a logged-out viewer sees them + * 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. + * 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. + * 3. Content-truth — the served content type is sniffed from the bytes, not the client-declared type, + * and only genuine raster images are served. A file spoofing `image/png` while holding HTML/SVG is + * refused rather than rendered inline. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + try { + const limited = await enforcePublicFileRateLimit(request, 'content') + if (limited) return limited + + const parsed = await parseRequest(getPublicInlineFileContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const ref = parsed.data.query + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + throw new FileNotFoundError('Not found') + } + + const auth = await validateDeploymentAuth( + requestId, + resolved.share, + request, + undefined, + 'file' + ) + if (!auth.authorized) { + return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) + } + + const { file: doc } = resolved + if (!doc.workspaceId) { + throw new FileNotFoundError('Not found') + } + + // 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) + if (!referenced) { + throw new FileNotFoundError('Not found') + } + + // Same-workspace gate: resolve scoped to the document's own workspace. + const image = await resolveWorkspaceInlineImage(doc.workspaceId, ref) + if (!image) { + throw new FileNotFoundError('Not found') + } + + // Content-truth gate (`sniff`): render only genuine raster image bytes. + return await serveInlineImage(image, { sniff: true }) + } catch (error) { + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } + logger.error('Error serving public inline image:', error) + return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + } + } +) diff --git a/apps/sim/app/api/files/serve-inline-image.ts b/apps/sim/app/api/files/serve-inline-image.ts new file mode 100644 index 00000000000..88c3383d961 --- /dev/null +++ b/apps/sim/app/api/files/serve-inline-image.ts @@ -0,0 +1,44 @@ +import { createLogger } from '@sim/logger' +import type { NextResponse } from 'next/server' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image' +import { sniffImageContentType } from '@/lib/uploads/utils/validation' +import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils' + +const logger = createLogger('InlineImageServe') + +/** + * A shared/edited/deleted file must never serve stale bytes from its fixed inline URL, so every inline + * image revalidates on each request. + */ +const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate' + +/** + * Download and respond with an already-workspace-scoped inline image — the single serving tail for both + * the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the + * served content type is derived from the bytes and non-raster content is refused with 404; otherwise the + * stored content type is served, matching the in-app serve route. + */ +export async function serveInlineImage( + image: ResolvedInlineImage, + { sniff }: { sniff: boolean } +): Promise { + const buffer = await downloadFile({ key: image.key, context: 'workspace' }) + + let contentType = image.contentType + if (sniff) { + const sniffed = sniffImageContentType(buffer) + if (!sniffed) { + logger.warn('Embedded reference is not a renderable image', { key: image.key }) + throw new FileNotFoundError('Not found') + } + contentType = sniffed + } + + return createFileResponse({ + buffer, + contentType, + filename: image.filename, + cacheControl: INLINE_CACHE_CONTROL, + }) +} diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts new file mode 100644 index 00000000000..3bb2a8a06ba --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetPerms: vi.fn(), + mockResolveImage: vi.fn(), + mockDownloadFile: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms })) +vi.mock('@/lib/uploads/server/inline-image', () => ({ + resolveWorkspaceInlineImage: mockResolveImage, +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) + +import { GET } from '@/app/api/workspaces/[id]/files/inline/route' + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) +const params = { params: Promise.resolve({ id: 'ws-1' }) } +const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`) + +describe('GET /api/workspaces/[id]/files/inline', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) + mockGetPerms.mockResolvedValue('read') + mockResolveImage.mockResolvedValue({ + key: 'workspace/ws-1/x-photo.png', + contentType: 'image/png', + filename: 'photo.png', + }) + mockDownloadFile.mockResolvedValue(PNG) + }) + + it('serves a workspace-scoped image by fileId', async () => { + const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(200) + expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' }) + }) + + it('serves a workspace-scoped image by key', async () => { + const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params) + expect(res.status).toBe(200) + }) + + it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => { + mockResolveImage.mockResolvedValue(null) + const res = await GET(req('fileId=wf_other'), params) + expect(res.status).toBe(404) + }) + + it('404s without workspace membership, before resolving the file', async () => { + mockGetPerms.mockResolvedValue(null) + const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(404) + expect(mockResolveImage).not.toHaveBeenCalled() + }) + + it('401s without a session', async () => { + mockGetSession.mockResolvedValue(null) + const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(401) + }) + + it('400s when neither key nor fileId is provided', async () => { + const res = await GET(req(''), params) + expect(res.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts new file mode 100644 index 00000000000..245fb5731d8 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -0,0 +1,59 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { serveInlineImage } from '@/app/api/files/serve-inline-image' +import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('WorkspaceInlineFileAPI') + +/** + * GET /api/workspaces/[id]/files/inline?key=|fileId= + * + * Serves an image embedded in a workspace markdown document, **scoped to the workspace in the path**. + * The markdown editor rewrites its embedded `/api/files/serve/` and `/api/files/view/` srcs to + * this route so a referenced file resolves only within the document's workspace — a cross-workspace + * reference returns 404 and does not render, even for a viewer who belongs to the other workspace. Read + * access to the workspace is required; disposition/content-type handling mirrors the serve route. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const parsed = await parseRequest(getInlineWorkspaceFileContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const ref = parsed.data.query + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Authorize before disclosing anything; deny with 404 so a non-member can't probe existence. + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + throw new FileNotFoundError('Not found') + } + + const image = await resolveWorkspaceInlineImage(workspaceId, ref) + if (!image) { + throw new FileNotFoundError('Not found') + } + + return await serveInlineImage(image, { sniff: false }) + } catch (error) { + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } + logger.error('Error serving workspace inline image:', error) + return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + } + } +) diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index f27b63df65d..360119e4945 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -9,7 +9,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { buildProvenance } from '@/app/f/[token]/utils' import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { useBrandConfig } from '@/ee/whitelabeling' -import { type FileContentSource, FileContentSourceProvider } from '@/hooks/use-file-content-source' +import { createPublicFileContentSource } from '@/hooks/use-file-content-source' interface PublicFileViewProps { token: string @@ -41,7 +41,12 @@ export function PublicFileView({ // `updatedAt` fold in the content version so the React Query caches (keyed on the // storage key + `updatedAt`) refetch when the shared file changes — even when its // size is unchanged. - const source = useMemo(() => ({ buildUrl: () => contentUrl }), [contentUrl]) + // Embedded images route through the token-scoped cascade endpoint, which serves them only when the + // shared document actually references them and they live in its workspace. + const source = useMemo( + () => createPublicFileContentSource(token, contentUrl), + [token, contentUrl] + ) const file = useMemo( () => ({ id: token, @@ -116,9 +121,13 @@ export function PublicFileView({
- - - +
) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 2e1d4d834ea..3d4f4b5d3e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -6,6 +6,11 @@ import dynamic from 'next/dynamic' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files' +import { + createWorkspaceFileContentSource, + type FileContentSource, + FileContentSourceProvider, +} from '@/hooks/use-file-content-source' import { CsvTablePreview } from './csv-table-preview' import { DocxPreview } from './docx-preview' import { resolveFileCategory } from './file-category' @@ -78,6 +83,12 @@ export type PreviewMode = 'editor' | 'split' | 'preview' interface FileViewerProps { file: WorkspaceFileRecord workspaceId: string + /** + * Content source for this view. Defaults to a workspace-scoped source derived from `workspaceId`; + * the public share page passes a token-scoped source. Provided to descendants (renderers, embedded + * images) via {@link FileContentSourceProvider}. + */ + contentSource?: FileContentSource canEdit: boolean /** * Render a read-only preview with no editing affordances. Text files render @@ -97,7 +108,20 @@ interface FileViewerProps { previewContextKey?: string } -export function FileViewer({ +export function FileViewer(props: FileViewerProps) { + const { contentSource, workspaceId } = props + const source = useMemo( + () => contentSource ?? createWorkspaceFileContentSource(workspaceId), + [contentSource, workspaceId] + ) + return ( + + + + ) +} + +function FileViewerContent({ file, workspaceId, canEdit, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts index 41e2f888408..a2879b6da6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts @@ -2,26 +2,41 @@ * @vitest-environment jsdom */ import { describe, expect, it } from 'vitest' -import { resolveDisplaySrc } from './image' +import { + createPublicFileContentSource, + createWorkspaceFileContentSource, +} from '@/hooks/use-file-content-source' -describe('resolveDisplaySrc', () => { - it('rewrites an in-app workspace file path to its serving endpoint (display only)', () => { - expect(resolveDisplaySrc('/workspace/W1/files/F123')).toBe('/api/files/view/F123') - expect(resolveDisplaySrc('/workspace/any-ws-id/files/abc-def')).toBe('/api/files/view/abc-def') +const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' +const ENCODED = encodeURIComponent(KEY) + +describe('content-source resolveImageSrc', () => { + it('in-app source rewrites embeds to the workspace-scoped inline route', () => { + const src = createWorkspaceFileContentSource('ws-1') + expect(src.resolveImageSrc(`/api/files/serve/${ENCODED}?context=workspace`)).toBe( + `/api/workspaces/ws-1/files/inline?key=${encodeURIComponent(KEY)}` + ) + expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe( + '/api/workspaces/ws-1/files/inline?fileId=wf_abc' + ) }) - it('leaves absolute and non-workspace URLs untouched', () => { - expect(resolveDisplaySrc('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png') - expect(resolveDisplaySrc('http://localhost/workspace/W1/files/F1')).toBe( - 'http://localhost/workspace/W1/files/F1' + it('public source rewrites embeds to the token-scoped inline route', () => { + const src = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content') + expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe( + '/api/files/public/tok_1/inline?fileId=wf_abc' ) - expect(resolveDisplaySrc('/other/path/files/x')).toBe('/other/path/files/x') - expect(resolveDisplaySrc('relative/image.png')).toBe('relative/image.png') }) - it('passes through empty/undefined and unparseable values', () => { - expect(resolveDisplaySrc(undefined)).toBeUndefined() - expect(resolveDisplaySrc('')).toBe('') - expect(resolveDisplaySrc('/workspace/W1/files/')).toBe('/workspace/W1/files/') + it('passes external/data srcs through unchanged in both sources', () => { + const ws = createWorkspaceFileContentSource('ws-1') + const pub = createPublicFileContentSource('tok_1', '/c') + expect(ws.resolveImageSrc('https://cdn.example.com/a.png')).toBe( + 'https://cdn.example.com/a.png' + ) + expect(pub.resolveImageSrc('https://cdn.example.com/a.png')).toBe( + 'https://cdn.example.com/a.png' + ) + expect(ws.resolveImageSrc(undefined)).toBeUndefined() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index 8e76a4244bb..5809af9625a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -3,6 +3,7 @@ import type { JSONContent } from '@tiptap/core' import { Image } from '@tiptap/extension-image' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { useFileContentSource } from '@/hooks/use-file-content-source' import { normalizeLinkHref } from './markdown-fidelity' const MIN_WIDTH = 64 @@ -26,24 +27,6 @@ function escapeAttr(value: string): string { .replace(/>/g, '>') } -/** - * Rewrite an in-app workspace file path (`/workspace/{id}/files/{fileId}`) to its serving endpoint - * (`/api/files/view/{fileId}`) for display only — the stored `src` attribute keeps the original path - * so markdown round-trips unchanged. Absolute and non-workspace URLs pass through untouched. - */ -export function resolveDisplaySrc(src: string | undefined): string | undefined { - if (!src) return src - try { - const parsed = new URL(src, 'http://placeholder') - if (parsed.origin !== 'http://placeholder') return src - const [, seg1, , seg3, fileId] = parsed.pathname.split('/') - if (seg1 === 'workspace' && seg3 === 'files' && fileId) return `/api/files/view/${fileId}` - } catch { - // not a parseable URL — render as-is - } - return src -} - /** * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to @@ -174,6 +157,7 @@ export const MarkdownImage = Image.extend({ * commits the new pixel width to the `width` attribute, which serializes to ``. */ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) { + const source = useFileContentSource() const imageRef = useRef(null) const dragAbortRef = useRef(null) const [dragging, setDragging] = useState(false) @@ -232,7 +216,7 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN const image = ( {attrs.alt): string { + return 'key' in ref + ? `key=${encodeURIComponent(ref.key)}` + : `fileId=${encodeURIComponent(ref.fileId)}` +} + /** * Seam for "where do a file's bytes come from". The in-app viewer resolves the * auth-gated workspace serve URL; the public share page swaps in a token-scoped @@ -19,18 +29,67 @@ export interface FileContentUrlOptions { */ export interface FileContentSource { buildUrl: (key: string, opts?: FileContentUrlOptions) => string + /** + * Map an embedded image `src` to a display URL scoped to the current context: the in-app source + * points at the workspace-scoped inline route, the public source at the token-scoped cascade route. + * Non-workspace srcs (external, `data:`, public assets) pass through unchanged. + */ + resolveImageSrc: (src: string | undefined) => string | undefined +} + +function buildServeUrl(key: string, opts?: FileContentUrlOptions): string { + const base = `/api/files/serve/${encodeURIComponent(key)}?context=workspace` + const params: string[] = [] + if (opts?.version != null) params.push(`v=${encodeURIComponent(String(opts.version))}`) + else if (opts?.bust) params.push(`t=${Date.now()}`) + if (opts?.raw) params.push('raw=1') + return params.length > 0 ? `${base}&${params.join('&')}` : base +} + +/** Build a source whose embeds resolve through `inlineBase` (the workspace- or token-scoped inline route). */ +function inlineImageSource( + buildUrl: FileContentSource['buildUrl'], + inlineBase: string +): FileContentSource { + return { + buildUrl, + resolveImageSrc: (src) => { + if (!src) return src + const ref = extractEmbeddedFileRef(src) + return ref ? `${inlineBase}?${inlineRefQuery(ref)}` : src + }, + } +} + +/** + * In-app source scoped to one workspace. Direct file bytes come from the workspace serve URL; embedded + * images route through `/api/workspaces/{workspaceId}/files/inline`, which resolves a reference only + * within this workspace — a cross-workspace embed 404s and does not render. + */ +export function createWorkspaceFileContentSource(workspaceId: string): FileContentSource { + return inlineImageSource(buildServeUrl, `/api/workspaces/${workspaceId}/files/inline`) +} + +/** + * Public share source. Direct file bytes come from the token content URL; embedded images route through + * `/api/files/public/{token}/inline`, which serves them only when referenced by the shared document and + * in its workspace. + */ +export function createPublicFileContentSource( + token: string, + contentUrl: string +): FileContentSource { + return inlineImageSource(() => contentUrl, `/api/files/public/${token}/inline`) } -/** Default source: the auth-gated workspace serve URL (the historical behavior). */ +/** + * Context default for components rendered outside a {@link FileContentSourceProvider}: serve URLs for + * direct bytes, embeds passed through unchanged. The file viewer always provides a workspace- or + * token-scoped source, so embeds resolve through the scoped inline routes there. + */ export const workspaceFileContentSource: FileContentSource = { - buildUrl: (key, opts) => { - const base = `/api/files/serve/${encodeURIComponent(key)}?context=workspace` - const params: string[] = [] - if (opts?.version != null) params.push(`v=${encodeURIComponent(String(opts.version))}`) - else if (opts?.bust) params.push(`t=${Date.now()}`) - if (opts?.raw) params.push('raw=1') - return params.length > 0 ? `${base}&${params.join('&')}` : base - }, + buildUrl: buildServeUrl, + resolveImageSrc: (src) => src, } const FileContentSourceContext = createContext(workspaceFileContentSource) diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 3f5391b5408..7e73175abc3 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -50,6 +50,33 @@ export const organizationIdSchema = z.string().min(1, 'Organization ID is requir */ export const workflowIdSchema = z.string().min(1, 'Workflow ID is required') +/** + * A `workspace_files.id` value. The column is a free-form `text` primary key, so + * ids come in two shapes: UUID v4 (legacy rows and the `insertFileMetadata` + * default) and the current `wf_` form minted by the workspace upload + * path. Both are drawn from `[A-Za-z0-9_-]`, so accept that charset rather than a + * UUID-only schema — a `.uuid()` constraint here silently 400s every `wf_` file. + */ +export const workspaceFileIdSchema = z + .string() + .min(1, 'File ID is required') + .max(128, 'File ID is too long') + .regex(/^[A-Za-z0-9_-]+$/, 'Invalid file id') + +/** + * Reference to an image embedded in a document: either a workspace storage `key` + * (serve-URL embeds) or a workspace file `id` (view-URL embeds) — exactly one. Shared + * by the in-app and public inline-image routes, which resolve it within a workspace. + */ +export const inlineFileRefQuerySchema = z + .object({ + key: z.string().min(1).max(512).optional(), + fileId: workspaceFileIdSchema.optional(), + }) + .refine((q) => (q.key ? 1 : 0) + (q.fileId ? 1 : 0) === 1, { + message: 'Provide exactly one of `key` or `fileId`', + }) + /** * Boolean query-string primitive that correctly handles the literal strings * `"true"` / `"false"` (case-insensitive) in addition to real booleans. diff --git a/apps/sim/lib/api/contracts/public-shares.ts b/apps/sim/lib/api/contracts/public-shares.ts index aa623324558..a839d372ead 100644 --- a/apps/sim/lib/api/contracts/public-shares.ts +++ b/apps/sim/lib/api/contracts/public-shares.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { inlineFileRefQuerySchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' export const shareResourceTypeSchema = z.enum(['file', 'folder']) @@ -126,6 +126,22 @@ export const getPublicFileContentContract = defineRouteContract({ }, }) +/** + * Binary stream of an image embedded in a shared document. Authorized by the parent + * document's active share — the route serves the bytes only when the reference is + * actually embedded in the shared document AND the file lives in the same workspace, + * and only when the bytes are a renderable raster image. + */ +export const getPublicInlineFileContract = defineRouteContract({ + method: 'GET', + path: '/api/files/public/[token]/inline', + params: publicFileTokenParamsSchema, + query: inlineFileRefQuerySchema, + response: { + mode: 'binary', + }, +}) + const authenticatePublicFileBodySchema = z.object({ password: z.string().min(1, 'Password is required').max(1024, 'Password is too long'), }) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 57de6d75b29..e5fef522c20 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -3,6 +3,7 @@ import { batchPresignedUploadResponseSchema, presignedUploadResponseSchema, } from '@/lib/api/contracts/file-uploads' +import { workspaceFileIdSchema } from '@/lib/api/contracts/primitives' import { type ContractBodyInput, type ContractJsonResponse, @@ -465,11 +466,11 @@ export const fileServeQuerySchema = z.object({ }) export const fileViewParamsSchema = z.object({ - id: z.string().uuid('File ID must be a valid UUID'), + id: workspaceFileIdSchema, }) export const fileExportParamsSchema = z.object({ - id: z.string().uuid('File ID must be a valid UUID'), + id: workspaceFileIdSchema, }) export const boxUploadContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index c7f1f6d5366..b47b5710528 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { inlineFileRefQuerySchema } from '@/lib/api/contracts/primitives' import { shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -16,6 +17,21 @@ export const listWorkspaceFilesQuerySchema = z.object({ scope: workspaceFileScopeSchema.default('active'), }) +/** + * Binary stream of an image embedded in a workspace markdown document, scoped to the + * workspace in the path. The route serves the bytes only when the referenced file is a + * `workspace` file belonging to `[id]` — cross-workspace references do not resolve. + */ +export const getInlineWorkspaceFileContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/files/inline', + params: workspaceFilesParamsSchema, + query: inlineFileRefQuerySchema, + response: { + mode: 'binary', + }, +}) + const workspaceFileNameSchema = z .string({ error: 'Name is required' }) .trim() diff --git a/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts b/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts new file mode 100644 index 00000000000..d4c33793f3f --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + extractEmbeddedImageIds, + extractEmbeddedImageKeys, +} from '@/lib/copilot/tools/server/files/embedded-image-refs' + +const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' + +describe('extractEmbeddedImageIds', () => { + it('extracts unique ids from view-url and in-app-path embeds (wf_ and uuid)', () => { + const a = 'wf_YwDXi8eWOkTxn0sbgChlB' + const b = '4bdaf6c4-072e-464e-891d-b6af3b5fe2cc' + const content = `![x](/api/files/view/${a}) ![y](/workspace/W1/files/${b}) ![dup](/api/files/view/${a})` + expect(extractEmbeddedImageIds(content).sort()).toEqual([b, a].sort()) + }) + + it('ignores serve-url, external, and plain content', () => { + expect( + extractEmbeddedImageIds(`![a](/api/files/serve/${encodeURIComponent(KEY)}) plain`) + ).toEqual([]) + }) + + it('caps the result at 50 ids', () => { + const content = Array.from( + { length: 60 }, + (_, i) => `/api/files/view/wf_${String(i).padStart(6, '0')}` + ).join(' ') + expect(extractEmbeddedImageIds(content)).toHaveLength(50) + }) +}) + +describe('extractEmbeddedImageKeys', () => { + it('extracts decoded workspace keys from serve-url embeds (encoded + s3/blob prefixed)', () => { + const content = `![a](/api/files/serve/${encodeURIComponent(KEY)}?context=workspace) ![b](/api/files/serve/s3/${encodeURIComponent(KEY)})` + expect(extractEmbeddedImageKeys(content)).toEqual([KEY]) + }) + + it('drops non-workspace keys (e.g. public profile pictures) and view-url embeds', () => { + const content = + '![a](/api/files/serve/profile-pictures%2Fu1%2Favatar.png) ![b](/api/files/view/wf_abc)' + expect(extractEmbeddedImageKeys(content)).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts b/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts index fb7a0eb5967..f5f7b9fbbad 100644 --- a/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts +++ b/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts @@ -1,8 +1,26 @@ import { getFileMetadataById } from '@/lib/uploads/server/metadata' +import { extractEmbeddedFileRefs } from '@/lib/uploads/utils/embedded-image-ref' -/** The canonical embed form the file agent writes for workspace images: `/api/files/view/`. */ +/** View-URL embed (`/api/files/view/`) — the only form the file agent writes; see {@link findUnembeddableImageRefs}. */ const VIEW_EMBED_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g +/** + * De-duplicated workspace file **ids** embedded in `content` (view URL or in-app workspace path). + * Shares the {@link extractEmbeddedFileRefs} grammar with the frontend renderer so the referenced-by-doc + * gate authorizes exactly what the client links. Resolution and access are checked by the caller. + */ +export function extractEmbeddedImageIds(content: string): string[] { + return extractEmbeddedFileRefs(content).ids +} + +/** + * De-duplicated workspace storage **keys** (`workspace//…`) embedded in `content` via the serve URL. + * Same shared grammar as {@link extractEmbeddedImageIds}. + */ +export function extractEmbeddedImageKeys(content: string): string[] { + return extractEmbeddedFileRefs(content).keys +} + /** * Returns the ids of `/api/files/view/` image embeds in `content` that will not render or survive a * workspace export. An embed is valid only when its id resolves to a workspace file in this same diff --git a/apps/sim/lib/uploads/server/inline-image.test.ts b/apps/sim/lib/uploads/server/inline-image.test.ts new file mode 100644 index 00000000000..ba774a3e8f1 --- /dev/null +++ b/apps/sim/lib/uploads/server/inline-image.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkspaceFile, mockGetFileMetadataByKey } = vi.hoisted(() => ({ + mockGetWorkspaceFile: vi.fn(), + mockGetFileMetadataByKey: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ getWorkspaceFile: mockGetWorkspaceFile })) +vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: mockGetFileMetadataByKey })) + +import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' + +describe('resolveWorkspaceInlineImage', () => { + beforeEach(() => vi.clearAllMocks()) + + it('resolves by fileId scoped to the workspace (getWorkspaceFile already enforces scope)', async () => { + mockGetWorkspaceFile.mockResolvedValue({ + key: 'workspace/ws-1/x.png', + type: 'image/png', + name: 'x.png', + }) + const out = await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' }) + expect(mockGetWorkspaceFile).toHaveBeenCalledWith('ws-1', 'wf_a') + expect(out).toEqual({ + key: 'workspace/ws-1/x.png', + contentType: 'image/png', + filename: 'x.png', + }) + }) + + it('returns null when getWorkspaceFile finds nothing (cross-workspace / deleted / non-workspace)', async () => { + mockGetWorkspaceFile.mockResolvedValue(null) + expect(await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' })).toBeNull() + }) + + it('resolves by key only when the row belongs to the workspace', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ + key: 'workspace/ws-1/x.png', + workspaceId: 'ws-1', + contentType: 'image/png', + originalName: 'x.png', + }) + const out = await resolveWorkspaceInlineImage('ws-1', { key: 'workspace/ws-1/x.png' }) + expect(out).toEqual({ + key: 'workspace/ws-1/x.png', + contentType: 'image/png', + filename: 'x.png', + }) + }) + + it('returns null when the keyed row belongs to a different workspace', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ + key: 'workspace/ws-2/x.png', + workspaceId: 'ws-2', + contentType: 'image/png', + originalName: 'x.png', + }) + expect(await resolveWorkspaceInlineImage('ws-1', { key: 'workspace/ws-2/x.png' })).toBeNull() + }) +}) diff --git a/apps/sim/lib/uploads/server/inline-image.ts b/apps/sim/lib/uploads/server/inline-image.ts new file mode 100644 index 00000000000..b44b9e08e4a --- /dev/null +++ b/apps/sim/lib/uploads/server/inline-image.ts @@ -0,0 +1,41 @@ +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' + +/** + * A markdown-embedded image reference: either a workspace file `fileId` (view-URL embeds) or a + * workspace storage `key` (serve-URL embeds). Exactly one is set — enforced at the route boundary. + */ +export interface InlineImageRef { + key?: string + fileId?: string +} + +/** The fields a serve handler needs to return an embedded image. */ +export interface ResolvedInlineImage { + key: string + contentType: string + filename: string +} + +/** + * Resolve an embedded-image reference to its storage key + metadata, **scoped to `workspaceId`**. + * Returns null whenever the reference is not a live `workspace` file in that workspace — a + * cross-workspace, non-workspace, missing, or deleted file. This is the single workspace-scope gate + * shared by the in-app inline route and the public-share cascade, mirroring how the user-facing file + * view resolves a file within its workspace ({@link getWorkspaceFile}). + */ +export async function resolveWorkspaceInlineImage( + workspaceId: string, + ref: InlineImageRef +): Promise { + if (ref.fileId) { + const file = await getWorkspaceFile(workspaceId, ref.fileId) + return file ? { key: file.key, contentType: file.type, filename: file.name } : null + } + if (ref.key) { + const record = await getFileMetadataByKey(ref.key, 'workspace') + if (!record || record.workspaceId !== workspaceId) return null + return { key: record.key, contentType: record.contentType, filename: record.originalName } + } + return null +} diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts new file mode 100644 index 00000000000..e4648342f78 --- /dev/null +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + extractEmbeddedFileRef, + extractEmbeddedFileRefs, +} from '@/lib/uploads/utils/embedded-image-ref' + +const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' +const ENCODED = encodeURIComponent(KEY) + +describe('extractEmbeddedFileRef', () => { + it('parses serve-url embeds (encoded, raw, and s3/blob prefixed) to the workspace key', () => { + expect(extractEmbeddedFileRef(`/api/files/serve/${ENCODED}?context=workspace`)).toEqual({ + key: KEY, + }) + expect(extractEmbeddedFileRef(`/api/files/serve/s3/${ENCODED}`)).toEqual({ key: KEY }) + expect(extractEmbeddedFileRef(`/api/files/serve/blob/${ENCODED}`)).toEqual({ key: KEY }) + }) + + it('parses view-url and in-app-path embeds to the file id', () => { + expect(extractEmbeddedFileRef('/api/files/view/wf_YwDXi8eWOkTxn0sbgChlB')).toEqual({ + fileId: 'wf_YwDXi8eWOkTxn0sbgChlB', + }) + expect(extractEmbeddedFileRef('/workspace/W1/files/wf_abc')).toEqual({ fileId: 'wf_abc' }) + }) + + it('returns null for external, data, and non-workspace serve urls', () => { + expect(extractEmbeddedFileRef('https://cdn.example.com/a.png')).toBeNull() + expect(extractEmbeddedFileRef('data:image/png;base64,AAAA')).toBeNull() + expect(extractEmbeddedFileRef('/api/files/serve/profile-pictures%2Fu1%2Favatar.png')).toBeNull() + }) +}) + +describe('extractEmbeddedFileRefs', () => { + it('collects de-duplicated keys and ids from a document via the shared parser', () => { + const content = ` + ![a](/api/files/serve/${ENCODED}?context=workspace) + ![b](/api/files/view/wf_abc) + ![c](/workspace/W1/files/4bdaf6c4-072e-464e-891d-b6af3b5fe2cc) + ![dup](/api/files/serve/s3/${ENCODED}) + ![ext](https://cdn.example.com/x.png) + ![pub](/api/files/serve/profile-pictures%2Fu1%2Favatar.png) + ` + const { keys, ids } = extractEmbeddedFileRefs(content) + expect(keys).toEqual([KEY]) + expect(ids.sort()).toEqual(['4bdaf6c4-072e-464e-891d-b6af3b5fe2cc', 'wf_abc'].sort()) + }) + + it('caps keys and ids at 50 each', () => { + const ids = Array.from( + { length: 60 }, + (_, i) => `/api/files/view/wf_${String(i).padStart(6, '0')}` + ) + expect(extractEmbeddedFileRefs(ids.join(' ')).ids).toHaveLength(50) + }) +}) diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts new file mode 100644 index 00000000000..072a37df324 --- /dev/null +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -0,0 +1,74 @@ +/** + * The grammar of a markdown-embedded workspace image reference, shared by the frontend renderer + * (which rewrites one `src` at a time) and the server (which scans a whole document for the + * referenced-by-doc gate and the export bundler). Both go through {@link extractEmbeddedFileRef} so + * the set the client links and the set the server authorizes can never drift apart. + * + * Pure and isomorphic — no DOM, Node, or DB imports — so it is safe to import from both client and + * server code. + */ + +/** A reference parsed from an embed `src`: a workspace storage key, a workspace file id, or neither. */ +export type EmbeddedFileRef = { key: string } | { fileId: string } | null + +/** Hard cap on embedded images resolved from one document — bounds export bundles and the cascade. */ +export const MAX_EMBEDDED_IMAGES = 50 + +/** + * Candidate embed URL substrings in document text: a serve URL, a view URL, or the in-app workspace + * path. The captured run stops at whitespace/quote/paren/angle/query so authoritative parsing is left + * to {@link extractEmbeddedFileRef}. + */ +const EMBED_URL_RE = + /(?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>?]*/g + +/** + * Parse a single embed `src` into the workspace file it references, normalizing the spellings the + * editor and file agent produce: `/api/files/serve/` (incl. `s3/`/`blob/` prefixes), `/api/files/view/`, + * and the in-app path `/workspace//files/`. Returns null for absolute, `data:`, or non-workspace + * URLs (e.g. public `profile-pictures/` assets), which render as-is. + */ +export function extractEmbeddedFileRef(src: string): EmbeddedFileRef { + try { + const parsed = new URL(src, 'http://placeholder') + if (parsed.origin !== 'http://placeholder') return null + const segs = parsed.pathname.split('/') + if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') { + let keySegs = segs.slice(4) + if (keySegs[0] === 's3' || keySegs[0] === 'blob') keySegs = keySegs.slice(1) + const raw = keySegs.join('/') + if (!raw) return null + const key = decodeURIComponent(raw) + return key.startsWith('workspace/') ? { key } : null + } + if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'view' && segs[4]) { + return { fileId: segs[4] } + } + if (segs[1] === 'workspace' && segs[3] === 'files' && segs[4]) { + return { fileId: segs[4] } + } + return null + } catch { + return null + } +} + +/** + * The de-duplicated keys and ids embedded in `content`, each capped at {@link MAX_EMBEDDED_IMAGES}. + * Every candidate URL is interpreted by {@link extractEmbeddedFileRef}, so this is exactly the set the + * frontend rewrites — the server's referenced-by-doc gate and the export bundler share one grammar. + */ +export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: string[] } { + const keys = new Set() + const ids = new Set() + for (const match of content.matchAll(EMBED_URL_RE)) { + const ref = extractEmbeddedFileRef(match[0]) + if (!ref) continue + if ('key' in ref) keys.add(ref.key) + else ids.add(ref.fileId) + } + return { + keys: [...keys].slice(0, MAX_EMBEDDED_IMAGES), + ids: [...ids].slice(0, MAX_EMBEDDED_IMAGES), + } +} diff --git a/apps/sim/lib/uploads/utils/validation.test.ts b/apps/sim/lib/uploads/utils/validation.test.ts index f5db99cbd09..9d5d31ea1d6 100644 --- a/apps/sim/lib/uploads/utils/validation.test.ts +++ b/apps/sim/lib/uploads/utils/validation.test.ts @@ -1,9 +1,40 @@ import { describe, expect, it } from 'vitest' import { SUPPORTED_ATTACHMENT_EXTENSIONS, + sniffImageContentType, validateAttachmentFileType, } from '@/lib/uploads/utils/validation' +describe('sniffImageContentType', () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]) + const gif87 = Buffer.from('GIF87a....', 'latin1') + const gif89 = Buffer.from('GIF89a....', 'latin1') + const webp = Buffer.concat([ + Buffer.from('RIFF', 'latin1'), + Buffer.from([0x00, 0x00, 0x00, 0x00]), + Buffer.from('WEBP', 'latin1'), + ]) + + it('detects real raster image formats from magic bytes', () => { + expect(sniffImageContentType(png)).toBe('image/png') + expect(sniffImageContentType(jpeg)).toBe('image/jpeg') + expect(sniffImageContentType(gif87)).toBe('image/gif') + expect(sniffImageContentType(gif89)).toBe('image/gif') + expect(sniffImageContentType(webp)).toBe('image/webp') + }) + + it('rejects non-image content, including image-shaped strings and SVG', () => { + expect( + sniffImageContentType(Buffer.from('', 'utf-8')) + ).toBeNull() + expect(sniffImageContentType(Buffer.from('', 'utf-8'))).toBeNull() + expect(sniffImageContentType(Buffer.from('RIFFxxxxAVI ', 'latin1'))).toBeNull() + expect(sniffImageContentType(Buffer.alloc(0))).toBeNull() + expect(sniffImageContentType(Buffer.from([0x89, 0x50]))).toBeNull() + }) +}) + describe('validateAttachmentFileType', () => { it('accepts image files (png, jpg, gif, webp, svg)', () => { expect(validateAttachmentFileType('screenshot.png')).toBeNull() diff --git a/apps/sim/lib/uploads/utils/validation.ts b/apps/sim/lib/uploads/utils/validation.ts index 4f46d67516a..b4e27684f63 100644 --- a/apps/sim/lib/uploads/utils/validation.ts +++ b/apps/sim/lib/uploads/utils/validation.ts @@ -335,6 +335,34 @@ export function isValidPng(buffer: Buffer): boolean { return buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_MAGIC_BYTES) } +/** + * Detect a renderable raster image from its leading bytes, returning the canonical MIME type or + * `null` when the content is not one of the inline-renderable image formats (PNG, JPEG, GIF, WebP). + * + * The stored `contentType` is client-declared and never sniffed at upload time, so any path that + * renders a file inline for a less-trusted audience (e.g. images embedded in a public share) must + * derive the served type from the bytes themselves — a file claiming `image/png` could be HTML, SVG, + * or a script. SVG is deliberately excluded: it can carry script and is not a raster format. + */ +export function sniffImageContentType(buffer: Buffer): string | null { + if (isValidPng(buffer)) return 'image/png' + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'image/jpeg' + } + if (buffer.length >= 6) { + const header = buffer.toString('latin1', 0, 6) + if (header === 'GIF87a' || header === 'GIF89a') return 'image/gif' + } + if ( + buffer.length >= 12 && + buffer.toString('latin1', 0, 4) === 'RIFF' && + buffer.toString('latin1', 8, 12) === 'WEBP' + ) { + return 'image/webp' + } + return null +} + export function validateMediaFileType( fileName: string, mimeType: string diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 17f0a25fa29..a45c2d84914 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 860, - zodRoutes: 860, + totalRoutes: 862, + zodRoutes: 862, nonZodRoutes: 0, } as const From 45e51f1ffa33d4c131737e43c0a7b14ff3a11217 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 24 Jun 2026 16:53:33 -0700 Subject: [PATCH 2/3] 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 --- .../rich-markdown-editor.tsx | 34 ++++++++++++++++++ .../slash-command/commands.test.ts | 36 ++++++++++++++++++- .../slash-command/commands.ts | 22 ++++++++++++ .../slash-command/slash-command.ts | 19 ++++++++-- 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 2a79be77190..d4d10637113 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -194,6 +194,11 @@ export function LoadedRichMarkdownEditor({ const uploadFile = useUploadWorkspaceFile() const editorInstanceRef = useRef(null) + // The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position + // captured when the command ran, so the upload inserts where `/Image` was typed. + const imageInputRef = useRef(null) + const pendingImagePosRef = useRef(null) + // Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest. const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => Promise.resolve() @@ -293,6 +298,19 @@ export function LoadedRichMarkdownEditor({ }) editorInstanceRef.current = editor + // Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is + // shared across instances). Reads only refs, so the handler stays stable across the editor's life. + useEffect(() => { + if (!editor) return + editor.storage.slashCommand.insertImage = (at: number) => { + pendingImagePosRef.current = at + imageInputRef.current?.click() + } + return () => { + editor.storage.slashCommand.insertImage = null + } + }, [editor]) + const wasStreamingRef = useRef(streamingAtMountRef.current) const pendingStreamBodyRef = useRef(null) @@ -386,6 +404,22 @@ export function LoadedRichMarkdownEditor({ > {editor && } {editor && } +