mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 = ``) {
|
||||
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(`}?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(``, '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'))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
+31
-16
@@ -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
-19
@@ -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 `<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
|
||||
|
||||
+34
@@ -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'
|
||||
|
||||
+35
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+22
@@ -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)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
+17
-2
@@ -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>({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = `  `
|
||||
expect(extractEmbeddedImageIds(content).sort()).toEqual([b, a].sort())
|
||||
})
|
||||
|
||||
it('ignores serve-url, external, and plain content', () => {
|
||||
expect(
|
||||
extractEmbeddedImageIds(`}) 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 = `}?context=workspace) })`
|
||||
expect(extractEmbeddedImageKeys(content)).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('drops non-workspace keys (e.g. public profile pictures) and view-url embeds', () => {
|
||||
const content =
|
||||
' '
|
||||
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 = `
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
`
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user