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
This commit is contained in:
Theodore Li
2026-06-24 21:07:39 -04:00
committed by GitHub
parent ae4bc05e60
commit cff7a49310
28 changed files with 1074 additions and 70 deletions
+7 -11
View File
@@ -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 })
@@ -139,10 +134,11 @@ export const GET = withRouteHandler(
for (const [imageId, asset] of assetMap) {
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const replacement = `./assets/${asset.filename}`
mdContent = mdContent.replace(
new RegExp(`/api/files/view/${escapedId}`, 'g'),
() => replacement
)
// Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app
// `/workspace/<ws>/files/<id>` path — so a bundled asset never leaves a broken link in the export.
mdContent = mdContent
.replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement)
.replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement)
}
const zip = new JSZip()
@@ -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('<svg/>', '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()
})
})
@@ -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=<cloudKey>|fileId=<id>
*
* 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'))
}
}
)
@@ -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<NextResponse> {
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,
})
}
@@ -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)
})
})
@@ -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=<cloudKey>|fileId=<id>
*
* 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/<key>` and `/api/files/view/<id>` 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'))
}
}
)
+14 -5
View File
@@ -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<FileContentSource>(() => ({ 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<WorkspaceFileRecord>(
() => ({
id: token,
@@ -116,9 +121,13 @@ export function PublicFileView({
</header>
<main className='flex min-h-0 flex-1 flex-col'>
<FileContentSourceProvider value={source}>
<FileViewer file={file} workspaceId={token} canEdit={false} readOnly />
</FileContentSourceProvider>
<FileViewer
file={file}
workspaceId={token}
contentSource={source}
canEdit={false}
readOnly
/>
</main>
</div>
)
@@ -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 (
<FileContentSourceProvider value={source}>
<FileViewerContent {...props} />
</FileContentSourceProvider>
)
}
function FileViewerContent({
file,
workspaceId,
canEdit,
@@ -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)
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'
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'
)
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('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'
)
})
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()
})
})
@@ -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, '&gt;')
}
/**
* 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 `<img>` 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 `<img width>`.
*/
function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) {
const source = useFileContentSource()
const imageRef = useRef<HTMLImageElement>(null)
const dragAbortRef = useRef<AbortController | null>(null)
const [dragging, setDragging] = useState(false)
@@ -232,7 +216,7 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
const image = (
<img
ref={imageRef}
src={resolveDisplaySrc(attrs.src)}
src={source.resolveImageSrc(attrs.src)}
alt={attrs.alt ?? ''}
title={attrs.title ?? undefined}
// When editable, the image itself is the drag handle — grab anywhere on it to reorder. (The node
@@ -194,6 +194,11 @@ export function LoadedRichMarkdownEditor({
const uploadFile = useUploadWorkspaceFile()
const editorInstanceRef = useRef<Editor | null>(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<HTMLInputElement>(null)
const pendingImagePosRef = useRef<number | null>(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<void>>(() =>
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<string | null>(null)
@@ -386,6 +404,22 @@ export function LoadedRichMarkdownEditor({
>
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard editor={editor} />}
<input
ref={imageInputRef}
type='file'
accept='image/*'
multiple
hidden
onChange={(event) => {
const input = event.currentTarget
const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/'))
const at =
pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0
pendingImagePosRef.current = null
input.value = ''
if (images.length > 0) void insertImagesRef.current(images, at)
}}
/>
<EditorContent
editor={editor}
className='mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
@@ -1,7 +1,8 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { Editor, Range } from '@tiptap/core'
import { describe, expect, it, vi } from 'vitest'
import { filterSlashCommands, SLASH_COMMANDS } from './commands'
describe('filterSlashCommands', () => {
@@ -48,4 +49,37 @@ describe('SLASH_COMMANDS registry', () => {
const titles = SLASH_COMMANDS.map((c) => c.title)
expect(new Set(titles).size).toBe(titles.length)
})
it('Image command replaces the trigger and hands the caret to the host insertImage handler', () => {
const insertImage = vi.fn()
const deleteRange = vi.fn(() => chain)
const chain = { focus: () => chain, deleteRange, run: () => true }
const editor = {
chain: () => chain,
storage: { slashCommand: { insertImage } },
state: { selection: { from: 7 } },
} as unknown as Editor
const image = SLASH_COMMANDS.find((c) => c.title === 'Image')
expect(image).toBeDefined()
image?.run({ editor, range: { from: 5, to: 6 } as Range })
expect(deleteRange).toHaveBeenCalledWith({ from: 5, to: 6 })
expect(insertImage).toHaveBeenCalledWith(7)
})
it('Image command is a no-op when no handler is wired', () => {
const chain = { focus: () => chain, deleteRange: () => chain, run: () => true }
const editor = {
chain: () => chain,
storage: { slashCommand: { insertImage: null } },
state: { selection: { from: 0 } },
} as unknown as Editor
expect(() =>
SLASH_COMMANDS.find((c) => c.title === 'Image')?.run({
editor,
range: { from: 0, to: 1 } as Range,
})
).not.toThrow()
})
})
@@ -4,6 +4,7 @@ import {
Heading1,
Heading2,
Heading3,
Image as ImageIcon,
List,
ListChecks,
ListOrdered,
@@ -19,6 +20,15 @@ export interface SlashCommandContext {
range: Range
}
/**
* Per-editor storage on the `slashCommand` extension. The host editor component sets `insertImage`
* after mount; it opens an image file picker and uploads + inserts the chosen image(s) at `at`. Null
* in headless/read-only contexts, where the Image command is a no-op.
*/
export interface SlashCommandStorage {
insertImage: ((at: number) => void) | null
}
export interface SlashCommandItem {
title: string
/** Group heading the item is shown under in the menu. */
@@ -131,6 +141,18 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [
aliases: ['hr', 'horizontal rule', 'separator'],
run: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: 'Image',
group: 'Media',
icon: ImageIcon,
aliases: ['picture', 'photo', 'upload', 'img'],
run: ({ editor, range }) => {
// Replace the typed `/query`, then hand off to the host component's picker, which uploads and
// inserts the image at the caret (the same path as paste/drop). No-op when no handler is wired.
editor.chain().focus().deleteRange(range).run()
editor.storage.slashCommand.insertImage?.(editor.state.selection.from)
},
},
]
/**
@@ -3,9 +3,20 @@ import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import Suggestion, { type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion'
import { filterSlashCommands, type SlashCommandContext, type SlashCommandItem } from './commands'
import {
filterSlashCommands,
type SlashCommandContext,
type SlashCommandItem,
type SlashCommandStorage,
} from './commands'
import { SlashCommandList, type SlashCommandListHandle } from './slash-command-list'
declare module '@tiptap/core' {
interface Storage {
slashCommand: SlashCommandStorage
}
}
type SlashSuggestionProps = SuggestionProps<SlashCommandItem, SlashCommandItem>
function positionPopup(element: HTMLElement, getRect: SlashSuggestionProps['clientRect']) {
@@ -76,9 +87,13 @@ function renderSlashSuggestion(): ReturnType<NonNullable<SuggestionOptions['rend
* Adds the `/` slash-command menu to the editor. Typing `/` at the start of a block — or after
* whitespace — opens {@link SlashCommandList}; selecting an item runs its block transform.
*/
export const SlashCommand = Extension.create({
export const SlashCommand = Extension.create<Record<string, never>, SlashCommandStorage>({
name: 'slashCommand',
addStorage() {
return { insertImage: null }
},
addProseMirrorPlugins() {
return [
Suggestion<SlashCommandItem, SlashCommandItem>({
+68 -9
View File
@@ -1,6 +1,10 @@
'use client'
import { createContext, useContext } from 'react'
import {
type EmbeddedFileRef,
extractEmbeddedFileRef,
} from '@/lib/uploads/utils/embedded-image-ref'
export interface FileContentUrlOptions {
/** Request the uncompiled source instead of the rendered/compiled bytes. */
@@ -11,6 +15,12 @@ export interface FileContentUrlOptions {
bust?: boolean
}
function inlineRefQuery(ref: NonNullable<EmbeddedFileRef>): 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
}
/** Default source: the auth-gated workspace serve URL (the historical behavior). */
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`)
}
/**
* 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<FileContentSource>(workspaceFileContentSource)
+27
View File
@@ -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_<shortId>` 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.
+17 -1
View File
@@ -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'),
})
@@ -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({
@@ -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()
@@ -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([])
})
})
@@ -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/<fileId>`. */
/** View-URL embed (`/api/files/view/<id>`) — 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/<wsId>/…`) 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/<id>` 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
@@ -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()
})
})
@@ -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<ResolvedInlineImage | null> {
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
}
@@ -0,0 +1,60 @@
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 total references (keys + ids) at 50 combined', () => {
const ids = Array.from(
{ length: 40 },
(_, i) => `/api/files/view/wf_${String(i).padStart(6, '0')}`
)
const keys = Array.from(
{ length: 40 },
(_, i) => `/api/files/serve/${encodeURIComponent(`workspace/W1/k${i}.png`)}`
)
const { keys: k, ids: d } = extractEmbeddedFileRefs([...ids, ...keys].join(' '))
expect(k.length + d.length).toBe(50)
})
})
@@ -0,0 +1,73 @@
/**
* 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/<key>` (incl. `s3/`/`blob/` prefixes), `/api/files/view/<id>`,
* and the in-app path `/workspace/<wsId>/files/<id>`. 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`, bounded to {@link MAX_EMBEDDED_IMAGES} unique
* references **combined** (keys + ids). 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<string>()
const ids = new Set<string>()
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)
if (keys.size + ids.size >= MAX_EMBEDDED_IMAGES) break
}
return { keys: [...keys], ids: [...ids] }
}
@@ -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('<html><script>x</script></html>', 'utf-8'))
).toBeNull()
expect(sniffImageContentType(Buffer.from('<svg xmlns="...">', '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()
+28
View File
@@ -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
+2 -2
View File
@@ -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