From 8d80229b8cbac18a5bfc4856eccf21af2c188d2a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 17:13:48 -0700 Subject: [PATCH 01/18] feat(files): add Markdown PDF download --- .../files/export/[id]/markdown-pdf.test.ts | 60 +++ .../api/files/export/[id]/markdown-pdf.tsx | 447 ++++++++++++++++++ .../app/api/files/export/[id]/route.test.ts | 77 ++- apps/sim/app/api/files/export/[id]/route.ts | 38 +- .../workspace/[workspaceId]/files/files.tsx | 21 +- .../sim/lib/api/contracts/storage-transfer.ts | 5 + apps/sim/lib/uploads/client/download.ts | 15 +- apps/sim/package.json | 3 + bun.lock | 89 ++++ 9 files changed, 745 insertions(+), 10 deletions(-) create mode 100644 apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts create mode 100644 apps/sim/app/api/files/export/[id]/markdown-pdf.tsx diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts new file mode 100644 index 00000000000..af3d81cfbd5 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { PDFDocument } from 'pdf-lib' +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' +import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' + +describe('Markdown PDF rendering', () => { + it('creates a valid multi-page PDF with GFM and an embedded image', async () => { + const image = await sharp({ + create: { + width: 120, + height: 60, + channels: 3, + background: '#4f46e5', + }, + }) + .png() + .toBuffer() + const repeatedParagraphs = Array.from( + { length: 70 }, + (_, index) => `Paragraph ${index + 1} with **bold**, _italic_, and \`inline code\`.` + ).join('\n\n') + const markdown = `# Export title + +> A useful blockquote with a [link](https://sim.ai). + +Smart quotes “work”, Greek Ω stays readable, and unsupported emoji 🚀 falls back safely. + +- First item +- Second item + +| Name | Value | +| --- | ---: | +| Alpha | 1 | +| Beta | 2 | + +\`\`\`ts +const exported = true +\`\`\` + +![Embedded image](/api/files/view/image-1) + +${repeatedParagraphs}` + + const buffer = await renderMarkdownPdf({ + markdown, + title: 'Export title', + images: new Map([['image-1', image]]), + }) + + expect(buffer.subarray(0, 4).toString()).toBe('%PDF') + expect(buffer.length).toBeGreaterThan(1_000) + + const document = await PDFDocument.load(buffer) + expect(document.getTitle()).toBe('Export title') + expect(document.getPageCount()).toBeGreaterThan(1) + }) +}) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx new file mode 100644 index 00000000000..50768fbae48 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -0,0 +1,447 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' +import type { ReactNode } from 'react' +import { + Document, + Font, + Image, + Link, + Page, + renderToBuffer, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer' +import { marked, type Token, type Tokens } from 'marked' +import sharp from 'sharp' + +type PdfImage = { data: Buffer; format: 'png' } + +interface GlyphFont { + hasGlyphForCodePoint(codePoint: number): boolean +} + +const FONT_DIR = join(process.cwd(), 'public', 'brand', 'fonts') +const GEIST_REGULAR = join(FONT_DIR, 'Geist-Regular.ttf') +const GEIST_MEDIUM = join(FONT_DIR, 'Geist-Medium.ttf') + +Font.register({ + family: 'Geist', + fonts: [ + { src: GEIST_REGULAR, fontStyle: 'normal', fontWeight: 400 }, + { src: GEIST_REGULAR, fontStyle: 'italic', fontWeight: 400 }, + { src: GEIST_MEDIUM, fontStyle: 'normal', fontWeight: 700 }, + { src: GEIST_MEDIUM, fontStyle: 'italic', fontWeight: 700 }, + ], +}) + +const require = createRequire(import.meta.url) +const { openSync } = require('fontkit') as { openSync(path: string): GlyphFont } +const geistGlyphs = openSync(GEIST_REGULAR) + +export interface MarkdownPdfInput { + markdown: string + title: string + images?: ReadonlyMap +} + +const styles = StyleSheet.create({ + page: { + backgroundColor: '#ffffff', + color: '#171717', + fontFamily: 'Geist', + fontSize: 10.5, + lineHeight: 1.45, + paddingBottom: 48, + paddingHorizontal: 48, + paddingTop: 48, + }, + paragraph: { marginBottom: 9 }, + h1: { fontSize: 23, fontWeight: 700, lineHeight: 1.2, marginBottom: 12, marginTop: 4 }, + h2: { fontSize: 19, fontWeight: 700, lineHeight: 1.25, marginBottom: 10, marginTop: 8 }, + h3: { fontSize: 16, fontWeight: 700, lineHeight: 1.3, marginBottom: 8, marginTop: 7 }, + h4: { fontSize: 13.5, fontWeight: 700, lineHeight: 1.35, marginBottom: 7, marginTop: 6 }, + h5: { fontSize: 11.5, fontWeight: 700, marginBottom: 6, marginTop: 5 }, + h6: { color: '#404040', fontSize: 10.5, fontWeight: 700, marginBottom: 5, marginTop: 4 }, + strong: { fontWeight: 700 }, + emphasis: { fontStyle: 'italic' }, + deleted: { textDecoration: 'line-through' }, + inlineCode: { + backgroundColor: '#f1f3f5', + color: '#24292f', + fontFamily: 'Geist', + fontSize: 9, + }, + link: { color: '#0969da', textDecoration: 'underline' }, + blockquote: { + borderLeftColor: '#b6bec8', + borderLeftWidth: 2, + color: '#4b5563', + marginBottom: 9, + paddingLeft: 10, + }, + codeBlock: { + backgroundColor: '#f6f8fa', + borderColor: '#d0d7de', + borderRadius: 3, + borderWidth: 0.5, + color: '#24292f', + fontFamily: 'Geist', + fontSize: 8.5, + lineHeight: 1.35, + marginBottom: 10, + padding: 9, + }, + list: { marginBottom: 8 }, + listItem: { flexDirection: 'row', marginBottom: 3 }, + listMarker: { flexShrink: 0, width: 22 }, + listBody: { flexBasis: 0, flexGrow: 1 }, + listText: { marginBottom: 2 }, + rule: { borderBottomColor: '#d0d7de', borderBottomWidth: 0.75, marginBottom: 12, marginTop: 4 }, + table: { borderColor: '#b6bec8', borderLeftWidth: 0.5, borderTopWidth: 0.5, marginBottom: 11 }, + tableRow: { flexDirection: 'row' }, + tableCell: { + borderBottomWidth: 0.5, + borderColor: '#b6bec8', + borderRightWidth: 0.5, + flexBasis: 0, + flexGrow: 1, + fontSize: 8.5, + minWidth: 0, + padding: 5, + }, + tableHeader: { backgroundColor: '#f1f3f5', fontWeight: 700 }, + imageBlock: { marginBottom: 11 }, + image: { maxHeight: 430, objectFit: 'contain', width: '100%' }, + imageFallback: { + backgroundColor: '#f6f8fa', + color: '#57606a', + fontStyle: 'italic', + marginBottom: 9, + padding: 8, + }, + htmlFallback: { color: '#57606a', marginBottom: 9 }, +}) + +function safeText(value: string): string { + // React PDF's standard fonts can map a missing glyph to an unrelated visible character. + // Use the same bundled font for measurement and rendering, with an explicit readable fallback. + let safe = '' + for (const character of value) { + const codePoint = character.codePointAt(0) + safe += codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) ? character : '?' + } + return safe +} + +function plainHtml(value: string): string { + return safeText(value.replace(/<[^>]*>/g, '').trim()) +} + +function safeLink(href: string): string | undefined { + try { + const url = new URL(href) + return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? href : undefined + } catch { + return undefined + } +} + +function embeddedImageId(href: string): string | undefined { + const match = + href.match(/\/api\/files\/view\/([^/?#]+)/) ?? + href.match(/\/workspace\/[^/]+\/files\/([^/?#]+)/) + if (!match?.[1]) return undefined + try { + return decodeURIComponent(match[1]) + } catch { + return match[1] + } +} + +function renderInline(tokens: Token[], keyPrefix: string): ReactNode[] { + return tokens.map((token, index) => { + const key = `${keyPrefix}-${index}` + switch (token.type) { + case 'text': + return token.tokens?.length ? renderInline(token.tokens, key) : safeText(token.text) + case 'escape': + return safeText(token.text) + case 'strong': { + const strong = token as Tokens.Strong + return ( + + {renderInline(strong.tokens, key)} + + ) + } + case 'em': { + const emphasis = token as Tokens.Em + return ( + + {renderInline(emphasis.tokens, key)} + + ) + } + case 'del': { + const deleted = token as Tokens.Del + return ( + + {renderInline(deleted.tokens, key)} + + ) + } + case 'codespan': + return ( + + {safeText(token.text)} + + ) + case 'br': + return '\n' + case 'link': { + const link = token as Tokens.Link + const href = safeLink(link.href) + const content = renderInline(link.tokens, key) + return href ? ( + + {content} + + ) : ( + + {content} + + ) + } + case 'image': + return safeText(token.text || token.href) + case 'html': + return plainHtml(token.text) + case 'checkbox': + return token.checked ? '[x] ' : '[ ] ' + default: + return 'text' in token && typeof token.text === 'string' ? safeText(token.text) : '' + } + }) +} + +function directImage(token: Token): Tokens.Image | undefined { + if (token.type === 'image') return token as Tokens.Image + if (token.type === 'link') { + const link = token as Tokens.Link + if (link.tokens.length === 1 && link.tokens[0]?.type === 'image') { + return link.tokens[0] as Tokens.Image + } + } + return undefined +} + +function renderImage(token: Tokens.Image, images: ReadonlyMap, key: string) { + const id = embeddedImageId(token.href) + const image = id ? images.get(id) : undefined + if (!image) { + return ( + + {token.text ? `Image: ${safeText(token.text)}` : 'Image unavailable'} + + ) + } + return ( + + + + ) +} + +function renderParagraph( + tokens: Token[], + images: ReadonlyMap, + keyPrefix: string +): ReactNode[] { + const output: ReactNode[] = [] + let inline: Token[] = [] + + const flushInline = () => { + if (inline.length === 0) return + output.push( + + {renderInline(inline, `${keyPrefix}-inline-${output.length}`)} + + ) + inline = [] + } + + for (const token of tokens) { + const image = directImage(token) + if (!image) { + inline.push(token) + continue + } + flushInline() + output.push(renderImage(image, images, `${keyPrefix}-image-${output.length}`)) + } + flushInline() + return output +} + +function renderList(token: Tokens.List, images: ReadonlyMap, key: string) { + const start = typeof token.start === 'number' ? token.start : 1 + return ( + + {token.items.map((item, index) => { + const marker = item.task + ? item.checked + ? '[x]' + : '[ ]' + : token.ordered + ? `${start + index}.` + : '-' + return ( + + {marker} + + {item.tokens.map((itemToken, tokenIndex) => + itemToken.type === 'text' ? ( + + {renderInline(itemToken.tokens ?? [itemToken], `${key}-${index}-${tokenIndex}`)} + + ) : ( + renderBlock(itemToken, images, `${key}-${index}-${tokenIndex}`) + ) + )} + + + ) + })} + + ) +} + +function renderTable(token: Tokens.Table, key: string) { + const row = (cells: Tokens.TableCell[], rowKey: string, header: boolean) => ( + + {cells.map((cell, index) => ( + + {renderInline(cell.tokens, `${rowKey}-${index}`)} + + ))} + + ) + + return ( + + {row(token.header, `${key}-header`, true)} + {token.rows.map((cells, index) => row(cells, `${key}-row-${index}`, false))} + + ) +} + +function renderBlock(token: Token, images: ReadonlyMap, key: string): ReactNode { + switch (token.type) { + case 'space': + case 'def': + return null + case 'heading': { + const heading = token as Tokens.Heading + const headingStyle = [styles.h1, styles.h2, styles.h3, styles.h4, styles.h5, styles.h6][ + Math.min(Math.max(heading.depth, 1), 6) - 1 + ] + return ( + + {renderInline(heading.tokens, key)} + + ) + } + case 'paragraph': { + const paragraph = token as Tokens.Paragraph + return {renderParagraph(paragraph.tokens, images, key)} + } + case 'text': + return ( + + {renderInline(token.tokens ?? [token], key)} + + ) + case 'code': + return ( + + {safeText(token.text)} + + ) + case 'blockquote': { + const blockquote = token as Tokens.Blockquote + return ( + + {blockquote.tokens.map((child, index) => renderBlock(child, images, `${key}-${index}`))} + + ) + } + case 'list': + return renderList(token as Tokens.List, images, key) + case 'table': + return renderTable(token as Tokens.Table, key) + case 'hr': + return + case 'html': { + const text = plainHtml(token.text) + return text ? ( + + {text} + + ) : null + } + default: + return 'text' in token && typeof token.text === 'string' ? ( + + {safeText(token.text)} + + ) : null + } +} + +function MarkdownDocument({ + markdown, + title, + images, +}: { + markdown: string + title: string + images: ReadonlyMap +}) { + const tokens = marked.lexer(markdown, { gfm: true }) + return ( + + + {tokens.map((token, index) => renderBlock(token, images, `block-${index}`))} + + + ) +} + +async function normalizeImages( + images: ReadonlyMap +): Promise> { + const normalized = new Map() + for (const [id, buffer] of images) { + try { + normalized.set(id, { data: await sharp(buffer).rotate().png().toBuffer(), format: 'png' }) + } catch { + // Keep the PDF usable when an otherwise downloadable attachment is not a renderable image. + } + } + return normalized +} + +export async function renderMarkdownPdf({ + markdown, + title, + images = new Map(), +}: MarkdownPdfInput): Promise { + const normalizedImages = await normalizeImages(images) + return renderToBuffer( + + ) +} diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index 069e1fc30a9..78ddfe77f84 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -12,12 +12,14 @@ const { mockVerifyFileAccess, mockDownloadFile, mockExtractEmbeddedImageIds, + mockRenderMarkdownPdf, } = vi.hoisted(() => ({ mockCheckAuth: vi.fn(), mockGetFileMetadataById: vi.fn(), mockVerifyFileAccess: vi.fn(), mockDownloadFile: vi.fn(), mockExtractEmbeddedImageIds: vi.fn(), + mockRenderMarkdownPdf: vi.fn(), })) vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth })) @@ -29,6 +31,9 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloa vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ extractEmbeddedImageIds: mockExtractEmbeddedImageIds, })) +vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ + renderMarkdownPdf: mockRenderMarkdownPdf, +})) vi.mock('@sim/audit', () => ({ recordAudit: vi.fn(), AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, @@ -42,8 +47,14 @@ const MB = 1024 * 1024 const DOC_ID = 'doc-1' const context = { params: Promise.resolve({ id: DOC_ID }) } -function request() { - return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/files/export/${DOC_ID}`) +function request(format?: 'pdf') { + const query = format ? `?format=${format}` : '' + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/files/export/${DOC_ID}${query}` + ) } function assetRecord(id: string, size: number) { @@ -78,6 +89,68 @@ describe('markdown export bundling', () => { ) mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) mockExtractEmbeddedImageIds.mockReturnValue([]) + mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + }) + + it('returns the stored Markdown unchanged when no format is requested', async () => { + const response = await GET(request(), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8') + expect(response.headers.get('Content-Disposition')).toContain('doc.md') + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('# Doc\n') + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + + it('renders Markdown as a directly downloadable PDF', async () => { + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Content-Disposition')).toContain('doc.pdf') + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('%PDF-generated') + expect(mockRenderMarkdownPdf).toHaveBeenCalledWith({ + markdown: '# Doc\n', + title: 'doc', + images: expect.any(Map), + }) + expect(mockRenderMarkdownPdf.mock.calls[0][0].images.size).toBe(0) + }) + + it('passes only authorized, readable embedded images to the PDF renderer', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['good', 'secret', 'broken']) + mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { + if (key.endsWith('doc.md')) return Buffer.from('![image](/api/files/view/good)') + if (key.endsWith('broken')) throw new Error('storage down') + return Buffer.from('png-bytes') + }) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map + expect(Array.from(images.keys())).toEqual(['good']) + expect(images.get('good')).toEqual(Buffer.from('png-bytes')) + }) + + it('rejects PDF format for a non-Markdown file', async () => { + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/doc.txt', + originalName: 'doc.txt', + contentType: 'text/plain', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('only available for Markdown') + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) it('rejects on declared asset bytes before downloading any of them', async () => { diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 0e578ada87b..8bbf7460def 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -19,6 +19,7 @@ import { downloadFile } from '@/lib/uploads/core/storage-service' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' +import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') @@ -66,6 +67,7 @@ export const GET = withRouteHandler( if (!parsed.success) return parsed.response const { id } = parsed.data.params + const { format } = parsed.data.query const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { @@ -90,7 +92,7 @@ export const GET = withRouteHandler( * markdown, or bundled zip) so a mid-export failure never logs a download * that never happened. */ - const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => { + const auditExport = (format: 'file' | 'markdown' | 'pdf' | 'zip', assetCount: number) => { recordAudit({ workspaceId: record.workspaceId ?? null, actorId: userId, @@ -121,6 +123,12 @@ export const GET = withRouteHandler( } if (!isMarkdown(record.originalName, record.contentType)) { + if (format === 'pdf') { + return NextResponse.json( + { error: 'PDF export is only available for Markdown files.' }, + { status: 400 } + ) + } const storagePrefix = getServeStoragePrefix() const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` auditExport('file', 0) @@ -151,9 +159,29 @@ export const GET = withRouteHandler( const imageIds = extractEmbeddedImageIds(mdContent) - logger.info('Exporting markdown', { id, imageCount: imageIds.length }) + logger.info('Exporting markdown', { + id, + format: format ?? 'source', + imageCount: imageIds.length, + }) + + const respondWithPdf = async (images: ReadonlyMap) => { + const title = record.originalName.replace(/\.(?:md|markdown)$/i, '') + const pdfName = safeFilename(`${title}.pdf`) + const pdfBuffer = await renderMarkdownPdf({ markdown: mdContent, title, images }) + auditExport('pdf', images.size) + return new NextResponse(new Uint8Array(pdfBuffer), { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(pdfName)}`, + 'Content-Length': String(pdfBuffer.length), + }, + }) + } if (imageIds.length === 0) { + if (format === 'pdf') return respondWithPdf(new Map()) const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') auditExport('markdown', 0) @@ -234,6 +262,12 @@ export const GET = withRouteHandler( assetMap.set(imageId, { filename, buffer }) } + if (format === 'pdf') { + return respondWithPdf( + new Map(Array.from(assetMap, ([imageId, asset]) => [imageId, asset.buffer])) + ) + } + for (const [imageId, asset] of assetMap) { const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const replacement = `./assets/${asset.filename}` diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..020447f5651 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -17,7 +17,7 @@ import { toast, Upload, } from '@sim/emcn' -import { Download, Send } from '@sim/emcn/icons' +import { Download, FileText, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' @@ -1062,9 +1062,9 @@ export function Files() { } const handleDownload = useCallback( - async (file: WorkspaceFileRecord) => { + async (file: WorkspaceFileRecord, format?: 'pdf') => { try { - await triggerFileDownload(file) + await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { workspace_id: workspaceId, is_bulk: false, @@ -1161,6 +1161,11 @@ export function Files() { if (file) handleDownload(file) }, [handleDownload]) + const handleDownloadPdfSelected = useCallback(() => { + const file = selectedFileRef.current + if (file) handleDownload(file, 'pdf') + }, [handleDownload]) + const handleDeleteSelected = useCallback(() => { const file = selectedFileRef.current if (file) { @@ -1627,6 +1632,15 @@ export function Files() { icon: Download, onSelect: handleDownloadSelected, }, + ...(isInlineMarkdown + ? [ + { + text: 'Download PDF', + icon: FileText, + onSelect: handleDownloadPdfSelected, + }, + ] + : []), ...(canEdit ? [ { @@ -1650,6 +1664,7 @@ export function Files() { handleCyclePreviewMode, handleTogglePreview, handleDownloadSelected, + handleDownloadPdfSelected, handleShareSelected, handleDeleteSelected, ]) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 2eb9e3b6a50..81047281341 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -291,6 +291,10 @@ export const fileExportParamsSchema = z.object({ id: workspaceFileIdSchema, }) +export const fileExportQuerySchema = z.object({ + format: z.literal('pdf').optional(), +}) + export const boxUploadContract = defineRouteContract({ method: 'POST', path: '/api/tools/box/upload', @@ -485,6 +489,7 @@ export const fileExportContract = defineRouteContract({ method: 'GET', path: '/api/files/export/[id]', params: fileExportParamsSchema, + query: fileExportQuerySchema, response: { mode: 'binary' }, }) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index ac873f66ae8..8c8e519b5c2 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -30,21 +30,30 @@ function fileNameFromDisposition(response: Response, fallback: string): string { return disposition.match(/filename="([^"]+)"/)?.[1] ?? fallback } -export async function triggerFileDownload(record: WorkspaceFileRecord): Promise { +export async function triggerFileDownload( + record: WorkspaceFileRecord, + options?: { format?: 'pdf' } +): Promise { const isMarkdown = record.type === 'text/markdown' || record.type === 'text/x-markdown' || /\.(?:md|markdown)$/i.test(record.name) + if (options?.format === 'pdf' && !isMarkdown) { + throw new Error('PDF export is only available for Markdown files') + } + const url = isMarkdown - ? `/api/files/export/${encodeURIComponent(record.id)}` + ? `/api/files/export/${encodeURIComponent(record.id)}${options?.format === 'pdf' ? '?format=pdf' : ''}` : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` // boundary-raw-fetch: binary download read as a blob; these paths have no contract const response = await fetch(url, { cache: 'no-store' }) if (!response.ok) throw new Error(`Failed to download "${record.name}"`) - saveBlob(await response.blob(), fileNameFromDisposition(response, record.name)) + const fallbackName = + options?.format === 'pdf' ? `${record.name.replace(/\.[^.]+$/, '')}.pdf` : record.name + saveBlob(await response.blob(), fileNameFromDisposition(response, fallbackName)) } /** diff --git a/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..2814cb16910 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -105,6 +105,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", + "@react-pdf/renderer": "4.5.1", "@sim/audit": "workspace:*", "@sim/auth": "workspace:*", "@sim/browser-protocol": "workspace:*", @@ -165,6 +166,7 @@ "echarts": "6.1.0", "es-toolkit": "1.45.1", "fluent-ffmpeg": "2.1.3", + "fontkit": "2.0.4", "framer-motion": "^12.5.0", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", @@ -188,6 +190,7 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", + "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", diff --git a/bun.lock b/bun.lock index 6fdf0020645..099f6fab5fb 100644 --- a/bun.lock +++ b/bun.lock @@ -208,6 +208,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", + "@react-pdf/renderer": "4.5.1", "@sim/audit": "workspace:*", "@sim/auth": "workspace:*", "@sim/browser-protocol": "workspace:*", @@ -268,6 +269,7 @@ "echarts": "6.1.0", "es-toolkit": "1.45.1", "fluent-ffmpeg": "2.1.3", + "fontkit": "2.0.4", "framer-motion": "^12.5.0", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", @@ -291,6 +293,7 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", + "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", @@ -1644,6 +1647,32 @@ "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="], + "@react-pdf/fns": ["@react-pdf/fns@3.1.3", "", {}, "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w=="], + + "@react-pdf/font": ["@react-pdf/font@4.0.8", "", { "dependencies": { "@react-pdf/pdfkit": "^5.1.1", "@react-pdf/types": "^2.11.1", "fontkit": "^2.0.2", "is-url": "^1.2.4" } }, "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA=="], + + "@react-pdf/image": ["@react-pdf/image@3.1.0", "", { "dependencies": { "@react-pdf/svg": "^1.1.0", "jay-peg": "^1.1.1", "png-js": "^2.0.0" } }, "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g=="], + + "@react-pdf/layout": ["@react-pdf/layout@4.6.1", "", { "dependencies": { "@react-pdf/fns": "3.1.3", "@react-pdf/image": "^3.1.0", "@react-pdf/primitives": "^4.3.0", "@react-pdf/stylesheet": "^6.2.1", "@react-pdf/textkit": "^6.3.0", "@react-pdf/types": "^2.11.1", "emoji-regex-xs": "^1.0.0", "queue": "^6.0.1", "yoga-layout": "^3.2.1" } }, "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA=="], + + "@react-pdf/pdfkit": ["@react-pdf/pdfkit@5.1.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "browserify-zlib": "^0.2.0", "fontkit": "^2.0.2", "jay-peg": "^1.1.1", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^2.0.0", "vite-compatible-readable-stream": "^3.6.1" } }, "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg=="], + + "@react-pdf/primitives": ["@react-pdf/primitives@4.3.0", "", {}, "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A=="], + + "@react-pdf/reconciler": ["@react-pdf/reconciler@2.0.0", "", { "dependencies": { "object-assign": "^4.1.1", "scheduler": "0.25.0-rc-603e6108-20241029" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw=="], + + "@react-pdf/render": ["@react-pdf/render@4.5.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.3", "@react-pdf/primitives": "^4.3.0", "@react-pdf/textkit": "^6.3.0", "@react-pdf/types": "^2.11.1", "abs-svg-path": "^0.1.1", "color-string": "^2.1.4", "normalize-svg-path": "^1.1.0", "parse-svg-path": "^0.1.2", "svg-arc-to-cubic-bezier": "^3.2.0" } }, "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA=="], + + "@react-pdf/renderer": ["@react-pdf/renderer@4.5.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.3", "@react-pdf/font": "^4.0.8", "@react-pdf/layout": "^4.6.1", "@react-pdf/pdfkit": "^5.1.1", "@react-pdf/primitives": "^4.3.0", "@react-pdf/reconciler": "^2.0.0", "@react-pdf/render": "^4.5.1", "@react-pdf/types": "^2.11.1", "events": "^3.3.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", "queue": "^6.0.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q=="], + + "@react-pdf/stylesheet": ["@react-pdf/stylesheet@6.2.1", "", { "dependencies": { "@react-pdf/fns": "3.1.3", "@react-pdf/types": "^2.11.1", "color-string": "^2.1.4", "hsl-to-hex": "^1.0.0", "media-engine": "^1.0.3", "postcss-value-parser": "^4.1.0" } }, "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A=="], + + "@react-pdf/svg": ["@react-pdf/svg@1.1.0", "", { "dependencies": { "@react-pdf/primitives": "^4.3.0" } }, "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA=="], + + "@react-pdf/textkit": ["@react-pdf/textkit@6.3.0", "", { "dependencies": { "@react-pdf/fns": "3.1.3", "bidi-js": "^1.0.2", "hyphen": "^1.6.4", "unicode-properties": "^1.4.1" } }, "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ=="], + + "@react-pdf/types": ["@react-pdf/types@2.11.1", "", { "dependencies": { "@react-pdf/font": "^4.0.8", "@react-pdf/primitives": "^4.3.0", "@react-pdf/stylesheet": "^6.2.1" } }, "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw=="], + "@reactflow/background": ["@reactflow/background@11.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA=="], "@reactflow/controls": ["@reactflow/controls@11.2.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw=="], @@ -2270,6 +2299,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -2380,6 +2411,8 @@ "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "binary-extensions": ["binary-extensions@3.1.0", "", {}, "sha512-Jvvd9hy1w+xUad8+ckQsWA/V1AoyubOvqn0aygjMOVM4BfIaRav1NFS3LsTSDaV4n4FtcCtQXvzep1E6MboqwQ=="], @@ -2400,8 +2433,12 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + "browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="], + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="], "buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], @@ -2486,6 +2523,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -2500,6 +2539,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -2710,6 +2751,8 @@ "devtools-protocol": ["devtools-protocol@0.0.1464554", "", {}, "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], @@ -2788,6 +2831,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -2946,6 +2991,8 @@ "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], @@ -3086,6 +3133,10 @@ "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "hsl-to-hex": ["hsl-to-hex@1.0.0", "", { "dependencies": { "hsl-to-rgb-for-reals": "^1.1.0" } }, "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA=="], + + "hsl-to-rgb-for-reals": ["hsl-to-rgb-for-reals@1.1.1", "", {}, "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], @@ -3122,6 +3173,8 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "hyphen": ["hyphen@1.14.1", "", {}, "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw=="], + "iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], @@ -3196,6 +3249,8 @@ "is-unsafe": ["is-unsafe@1.0.1", "", {}, "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA=="], + "is-url": ["is-url@1.2.4", "", {}, "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -3222,6 +3277,8 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jay-peg": ["jay-peg@1.1.1", "", { "dependencies": { "restructure": "^3.0.0" } }, "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], @@ -3230,6 +3287,8 @@ "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], @@ -3444,6 +3503,8 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-engine": ["media-engine@1.0.3", "", {}, "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], @@ -3636,6 +3697,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "^3.0.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="], + "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], "notepack.io": ["notepack.io@3.0.1", "", {}, "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg=="], @@ -3710,6 +3773,8 @@ "parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q=="], + "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -3786,6 +3851,8 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "png-js": ["png-js@2.0.0", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA=="], + "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -3838,6 +3905,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], @@ -3912,6 +3981,8 @@ "react-hook-form": ["react-hook-form@7.79.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-pdf": ["react-pdf@10.4.1", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="], "react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="], @@ -4010,6 +4081,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -4244,6 +4317,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svg-arc-to-cubic-bezier": ["svg-arc-to-cubic-bezier@3.2.0", "", {}, "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="], + "svix": ["svix@1.88.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], @@ -4376,6 +4451,8 @@ "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -4432,6 +4509,8 @@ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite-compatible-readable-stream": ["vite-compatible-readable-stream@3.6.1", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ=="], + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], @@ -4518,6 +4597,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="], "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], @@ -4784,6 +4865,12 @@ "@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@react-pdf/pdfkit/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@react-pdf/pdfkit/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@react-pdf/reconciler/scheduler": ["scheduler@0.25.0-rc-603e6108-20241029", "", {}, "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="], + "@reactflow/background/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "@reactflow/controls/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -4950,6 +5037,8 @@ "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "color-string/color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], From 538beb6815e7bfb92f3f290916a924e8da2bb1a6 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 17:39:20 -0700 Subject: [PATCH 02/18] style(files): name Markdown PDF props --- apps/sim/app/api/files/export/[id]/markdown-pdf.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 50768fbae48..c4fcfd79078 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -402,15 +402,13 @@ function renderBlock(token: Token, images: ReadonlyMap, key: s } } -function MarkdownDocument({ - markdown, - title, - images, -}: { +interface MarkdownDocumentProps { markdown: string title: string images: ReadonlyMap -}) { +} + +function MarkdownDocument({ markdown, title, images }: MarkdownDocumentProps) { const tokens = marked.lexer(markdown, { gfm: true }) return ( From 78cebab81b7f42952fa27a68c8c2c255024eb3a2 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 18:14:28 -0700 Subject: [PATCH 03/18] fix(files): harden Markdown PDF export --- .../files/export/[id]/markdown-pdf.test.ts | 17 ++- .../api/files/export/[id]/markdown-pdf.tsx | 72 +++++++++-- .../app/api/files/export/[id]/route.test.ts | 117 +++++++++++++++++- apps/sim/app/api/files/export/[id]/route.ts | 42 +++++-- .../workspace/[workspaceId]/files/files.tsx | 15 +++ apps/sim/lib/uploads/client/download.ts | 21 ++-- 6 files changed, 257 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts index af3d81cfbd5..b6afb082a81 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -40,7 +40,7 @@ Smart quotes “work”, Greek Ω stays readable, and unsupported emoji 🚀 fal const exported = true \`\`\` -![Embedded image](/api/files/view/image-1) +![Embedded image](/workspace/ws-1/files/image-1) ${repeatedParagraphs}` @@ -57,4 +57,19 @@ ${repeatedParagraphs}` expect(document.getTitle()).toBe('Export title') expect(document.getPageCount()).toBeGreaterThan(1) }) + + it('falls back instead of decoding an image above the pixel ceiling', async () => { + const oversizedSvg = Buffer.from( + '' + ) + + const buffer = await renderMarkdownPdf({ + markdown: '![Too large](/api/files/view/image-1)', + title: 'Bounded image', + images: new Map([['image-1', oversizedSvg]]), + }) + + expect(buffer.subarray(0, 4).toString()).toBe('%PDF') + expect((await PDFDocument.load(buffer)).getPageCount()).toBe(1) + }) }) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index c4fcfd79078..9157d116c87 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -14,6 +14,7 @@ import { } from '@react-pdf/renderer' import { marked, type Token, type Tokens } from 'marked' import sharp from 'sharp' +import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' type PdfImage = { data: Buffer; format: 'png' } @@ -25,6 +26,18 @@ const FONT_DIR = join(process.cwd(), 'public', 'brand', 'fonts') const GEIST_REGULAR = join(FONT_DIR, 'Geist-Regular.ttf') const GEIST_MEDIUM = join(FONT_DIR, 'Geist-Medium.ttf') +/** + * PDF images never render wider than the A4 content box, so retaining camera-resolution + * rasters only increases Sharp and React PDF work. The dimension matches the app's existing + * inline-image preparation ceiling; the aggregate budgets bound work across a document. + */ +const MAX_PDF_IMAGE_DIMENSION = 1568 +const MAX_PDF_IMAGE_INPUT_PIXELS = 268_402_689 +const MAX_PDF_TOTAL_INPUT_PIXELS = 268_402_689 +const MAX_PDF_TOTAL_OUTPUT_PIXELS = 25_000_000 +const MAX_PDF_IMAGE_BYTES = 12 * 1024 * 1024 +const MAX_PDF_TOTAL_IMAGE_BYTES = 32 * 1024 * 1024 + Font.register({ family: 'Geist', fonts: [ @@ -148,15 +161,8 @@ function safeLink(href: string): string | undefined { } function embeddedImageId(href: string): string | undefined { - const match = - href.match(/\/api\/files\/view\/([^/?#]+)/) ?? - href.match(/\/workspace\/[^/]+\/files\/([^/?#]+)/) - if (!match?.[1]) return undefined - try { - return decodeURIComponent(match[1]) - } catch { - return match[1] - } + const ref = extractEmbeddedFileRef(href) + return ref && 'fileId' in ref ? ref.fileId : undefined } function renderInline(tokens: Token[], keyPrefix: string): ReactNode[] { @@ -423,9 +429,55 @@ async function normalizeImages( images: ReadonlyMap ): Promise> { const normalized = new Map() + let totalInputPixels = 0 + let totalOutputPixels = 0 + let totalImageBytes = 0 + for (const [id, buffer] of images) { try { - normalized.set(id, { data: await sharp(buffer).rotate().png().toBuffer(), format: 'png' }) + const pipeline = sharp(buffer, { limitInputPixels: MAX_PDF_IMAGE_INPUT_PIXELS }) + const metadata = await pipeline.metadata() + if (!metadata.width || !metadata.height) continue + + const inputPixels = metadata.width * metadata.height + if ( + !Number.isSafeInteger(inputPixels) || + totalInputPixels + inputPixels > MAX_PDF_TOTAL_INPUT_PIXELS + ) { + continue + } + totalInputPixels += inputPixels + + const scale = Math.min( + 1, + MAX_PDF_IMAGE_DIMENSION / metadata.width, + MAX_PDF_IMAGE_DIMENSION / metadata.height + ) + const outputWidth = Math.max(1, Math.round(metadata.width * scale)) + const outputHeight = Math.max(1, Math.round(metadata.height * scale)) + const outputPixels = outputWidth * outputHeight + if (totalOutputPixels + outputPixels > MAX_PDF_TOTAL_OUTPUT_PIXELS) continue + + const data = await pipeline + .rotate() + .resize({ + width: MAX_PDF_IMAGE_DIMENSION, + height: MAX_PDF_IMAGE_DIMENSION, + fit: 'inside', + withoutEnlargement: true, + }) + .png() + .toBuffer() + if ( + data.length > MAX_PDF_IMAGE_BYTES || + totalImageBytes + data.length > MAX_PDF_TOTAL_IMAGE_BYTES + ) { + continue + } + + totalOutputPixels += outputPixels + totalImageBytes += data.length + normalized.set(id, { data, format: 'png' }) } catch { // Keep the PDF usable when an otherwise downloadable attachment is not a renderable image. } diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index 78ddfe77f84..ed85e2e2f59 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -13,6 +13,9 @@ const { mockDownloadFile, mockExtractEmbeddedImageIds, mockRenderMarkdownPdf, + mockEnforceUserRateLimit, + mockRecordAudit, + mockCaptureServerEvent, } = vi.hoisted(() => ({ mockCheckAuth: vi.fn(), mockGetFileMetadataById: vi.fn(), @@ -20,6 +23,9 @@ const { mockDownloadFile: vi.fn(), mockExtractEmbeddedImageIds: vi.fn(), mockRenderMarkdownPdf: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockRecordAudit: vi.fn(), + mockCaptureServerEvent: vi.fn(), })) vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth })) @@ -34,12 +40,15 @@ vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ renderMarkdownPdf: mockRenderMarkdownPdf, })) +vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) vi.mock('@sim/audit', () => ({ - recordAudit: vi.fn(), + recordAudit: mockRecordAudit, AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, AuditResourceType: { FILE: 'file' }, })) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) import { GET } from '@/app/api/files/export/[id]/route' @@ -90,6 +99,7 @@ describe('markdown export bundling', () => { mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) mockExtractEmbeddedImageIds.mockReturnValue([]) mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + mockEnforceUserRateLimit.mockResolvedValue(null) }) it('returns the stored Markdown unchanged when no format is requested', async () => { @@ -100,6 +110,7 @@ describe('markdown export bundling', () => { expect(response.headers.get('Content-Disposition')).toContain('doc.md') expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('# Doc\n') expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + expect(mockEnforceUserRateLimit).not.toHaveBeenCalled() }) it('renders Markdown as a directly downloadable PDF', async () => { @@ -115,6 +126,23 @@ describe('markdown export bundling', () => { images: expect.any(Map), }) expect(mockRenderMarkdownPdf.mock.calls[0][0].images.size).toBe(0) + expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('markdown-pdf-export', 'user-1', { + maxTokens: 3, + refillRate: 3, + refillIntervalMs: 60_000, + }) + }) + + it('stops a rate-limited PDF export before reading the document', async () => { + mockEnforceUserRateLimit.mockResolvedValue( + new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 }) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(429) + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) it('passes only authorized, readable embedded images to the PDF renderer', async () => { @@ -134,6 +162,37 @@ describe('markdown export bundling', () => { expect(images.get('good')).toEqual(Buffer.from('png-bytes')) }) + it('records an image-containing PDF as one downloaded file', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + + await GET(request('pdf'), context) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ assetCount: 1, format: 'pdf' }), + }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + expect.objectContaining({ file_count: 1, is_bulk: false }), + { groups: { workspace: 'ws-1' } } + ) + }) + + it('keeps image-containing ZIP telemetry bulk', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + + await GET(request(), context) + + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + expect.objectContaining({ file_count: 2, is_bulk: true }), + { groups: { workspace: 'ws-1' } } + ) + }) + it('rejects PDF format for a non-Markdown file', async () => { mockGetFileMetadataById.mockResolvedValue({ id: DOC_ID, @@ -197,6 +256,25 @@ describe('markdown export bundling', () => { expect(bodyCall?.[0].maxBytes).toBe(250 * MB) }) + it('uses a smaller document limit for PDF rendering', async () => { + await GET(request('pdf'), context) + + const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) + expect(bodyCall?.[0].maxBytes).toBe(256 * 1024) + }) + + it('reports an oversized PDF body with the PDF-specific limit', async () => { + mockDownloadFile.mockRejectedValue( + new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('256 KB PDF export limit') + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + it('reports an oversized body as a size rejection, not a server error', async () => { mockExtractEmbeddedImageIds.mockReturnValue([]) mockDownloadFile.mockRejectedValue( @@ -221,6 +299,41 @@ describe('markdown export bundling', () => { expect(assetCall?.[0].maxBytes).toBe(25 * MB) }) + it('uses a smaller per-asset limit for PDF rendering', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['a']) + + await GET(request('pdf'), context) + + const assetCall = mockDownloadFile.mock.calls.find( + ([options]) => options.key === 'workspace/ws-1/a' + ) + expect(assetCall?.[0].maxBytes).toBe(10 * MB) + }) + + it('rejects PDF source material above its aggregate input limit', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b']) + mockGetFileMetadataById.mockImplementation(async (id: string) => + id === DOC_ID + ? { + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + } + : assetRecord(id, 30 * MB) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('50 MB PDF export limit') + expect(mockDownloadFile).toHaveBeenCalledTimes(1) + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + it('drops an unreadable asset instead of failing the whole export', async () => { mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad']) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 8bbf7460def..a3ea5e8c110 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -9,6 +9,8 @@ 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 type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -35,6 +37,17 @@ const logger = createLogger('FilesExportAPI') */ const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 +/** Matches the editor's p99-plus document ceiling; larger files would create an unbounded PDF layout tree. */ +const MAX_PDF_MARKDOWN_BYTES = 256 * 1024 +const MAX_PDF_ASSET_BYTES = 10 * 1024 * 1024 +const MAX_PDF_TOTAL_SOURCE_BYTES = 50 * 1024 * 1024 + +/** PDF rendering is CPU-bound and buffers its result, so it gets a narrower request bucket than downloads. */ +const PDF_EXPORT_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 3, + refillIntervalMs: 60_000, +} const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) @@ -110,13 +123,14 @@ export const GET = withRouteHandler( }, request, }) + const downloadedFileCount = format === 'zip' ? 1 + assetCount : 1 captureServerEvent( userId, 'file_downloaded', { ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), - is_bulk: assetCount > 0, - file_count: 1 + assetCount, + is_bulk: downloadedFileCount > 1, + file_count: downloadedFileCount, }, record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined ) @@ -135,22 +149,35 @@ export const GET = withRouteHandler( return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) } + if (format === 'pdf') { + const rateLimited = await enforceUserRateLimit( + 'markdown-pdf-export', + userId, + PDF_EXPORT_RATE_LIMIT + ) + if (rateLimited) return rateLimited + } + // Capped like everything else in the bundle: the document body is usually the // largest single entry, so leaving it unbounded left the export limit unenforced // against the one item most able to exceed it. A body that alone exceeds the limit // is a size rejection, so it reports as one rather than as a server error. let mdBuffer: Buffer + const documentLimit = format === 'pdf' ? MAX_PDF_MARKDOWN_BYTES : MAX_EXPORT_TOTAL_BYTES try { mdBuffer = await downloadFile({ key: record.key, context: record.context as StorageContext, - maxBytes: MAX_EXPORT_TOTAL_BYTES, + maxBytes: documentLimit, }) } catch (error) { if (!isPayloadSizeLimitError(error)) throw error return NextResponse.json( { - error: `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, + error: + format === 'pdf' + ? `This document exceeds the ${formatFileSize(MAX_PDF_MARKDOWN_BYTES)} PDF export limit.` + : `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, }, { status: 400 } ) @@ -218,10 +245,11 @@ export const GET = withRouteHandler( // limit that measured only the attachments would not describe the archive produced. const bundleBytes = mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0) - if (bundleBytes > MAX_EXPORT_TOTAL_BYTES) { + const bundleLimit = format === 'pdf' ? MAX_PDF_TOTAL_SOURCE_BYTES : MAX_EXPORT_TOTAL_BYTES + if (bundleBytes > bundleLimit) { return NextResponse.json( { - error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, + error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(bundleLimit)} ${format === 'pdf' ? 'PDF ' : ''}export limit.`, }, { status: 400 } ) @@ -235,7 +263,7 @@ export const GET = withRouteHandler( const buffer = await downloadFile({ key: imgRecord.key, context: imgRecord.context as StorageContext, - maxBytes: MAX_EXPORT_ASSET_BYTES, + maxBytes: format === 'pdf' ? MAX_PDF_ASSET_BYTES : MAX_EXPORT_ASSET_BYTES, }) return { imageId, originalName: imgRecord.originalName, buffer } } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 020447f5651..681061e9435 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -345,6 +345,8 @@ export function Files() { ) const [creatingFile, setCreatingFile] = useState(false) + const [pdfDownloadPending, setPdfDownloadPending] = useState(false) + const pdfDownloadPendingRef = useRef(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) @@ -1063,6 +1065,12 @@ export function Files() { const handleDownload = useCallback( async (file: WorkspaceFileRecord, format?: 'pdf') => { + const isPdf = format === 'pdf' + if (isPdf) { + if (pdfDownloadPendingRef.current) return + pdfDownloadPendingRef.current = true + setPdfDownloadPending(true) + } try { await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { @@ -1073,6 +1081,11 @@ export function Files() { } catch (err) { logger.error('Failed to download file:', err) toast.error(getErrorMessage(err, `Failed to download "${file.name}"`)) + } finally { + if (isPdf) { + pdfDownloadPendingRef.current = false + setPdfDownloadPending(false) + } } }, [workspaceId] @@ -1638,6 +1651,7 @@ export function Files() { text: 'Download PDF', icon: FileText, onSelect: handleDownloadPdfSelected, + disabled: pdfDownloadPending, }, ] : []), @@ -1667,6 +1681,7 @@ export function Files() { handleDownloadPdfSelected, handleShareSelected, handleDeleteSelected, + pdfDownloadPending, ]) const listRenameRef = useRef(listRename) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index 8c8e519b5c2..97655058229 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -1,4 +1,5 @@ import { requestRaw } from '@/lib/api/client/request' +import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -43,13 +44,19 @@ export async function triggerFileDownload( throw new Error('PDF export is only available for Markdown files') } - const url = isMarkdown - ? `/api/files/export/${encodeURIComponent(record.id)}${options?.format === 'pdf' ? '?format=pdf' : ''}` - : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` - - // boundary-raw-fetch: binary download read as a blob; these paths have no contract - const response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + let response: Response + if (isMarkdown) { + response = await requestRaw( + fileExportContract, + { params: { id: record.id }, query: { format: options?.format } }, + { cache: 'no-store' } + ) + } else { + const url = `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` + // boundary-raw-fetch: legacy binary serve URL includes context and cache-busting query fields outside the serve contract + response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + } const fallbackName = options?.format === 'pdf' ? `${record.name.replace(/\.[^.]+$/, '')}.pdf` : record.name From aa8ddeb0ac388097af6adfe671ceecf6e3de643d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 19:38:23 -0700 Subject: [PATCH 04/18] fix(files): align PDF export with Markdown editor --- .../files/export/[id]/markdown-pdf.test.ts | 81 ++- .../api/files/export/[id]/markdown-pdf.tsx | 628 ++++++++++++------ .../app/api/files/export/[id]/route.test.ts | 175 +++-- apps/sim/app/api/files/export/[id]/route.ts | 100 +-- apps/sim/lib/collab-doc/server-markdown.ts | 22 + .../lib/uploads/server/inline-image.test.ts | 4 + apps/sim/lib/uploads/server/inline-image.ts | 12 +- .../uploads/utils/embedded-image-ref.test.ts | 32 + .../lib/uploads/utils/embedded-image-ref.ts | 27 +- apps/sim/next.config.ts | 1 + apps/sim/package.json | 5 +- bun.lock | 13 +- docker/app.Dockerfile | 5 + 13 files changed, 781 insertions(+), 324 deletions(-) create mode 100644 apps/sim/lib/collab-doc/server-markdown.ts diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts index b6afb082a81..53c88adfffd 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -2,12 +2,30 @@ * @vitest-environment node */ import { PDFDocument } from 'pdf-lib' +import { getDocument, OPS } from 'pdfjs-dist/legacy/build/pdf.mjs' import sharp from 'sharp' import { describe, expect, it } from 'vitest' -import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' +import { MarkdownPdfLimitError, renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' + +async function pdfPagesText(buffer: Buffer): Promise { + const document = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }).promise + try { + return await Promise.all( + Array.from({ length: document.numPages }, async (_, index) => { + const page = await document.getPage(index + 1) + const content = await page.getTextContent() + return content.items.map((item) => ('str' in item ? item.str : '')).join(' ') + }) + ) + } finally { + await document.destroy() + } +} describe('Markdown PDF rendering', () => { it('creates a valid multi-page PDF with GFM and an embedded image', async () => { + const imageKey = 'workspace/ws-1/editor-image.png' + const imageUrl = `/api/files/serve/${encodeURIComponent(imageKey)}?context=workspace` const image = await sharp({ create: { width: 120, @@ -26,7 +44,9 @@ describe('Markdown PDF rendering', () => { > A useful blockquote with a [link](https://sim.ai). -Smart quotes “work”, Greek Ω stays readable, and unsupported emoji 🚀 falls back safely. +Smart quotes “work” and Greek Ω stays readable. + +中文排版应该清晰易读。 العربية يجب أن تكون متصلة ومقروءة. हिन्दी पाठ स्पष्ट और पठनीय होना चाहिए। עברית צריכה להיות ברורה וקריאה. - First item - Second item @@ -40,14 +60,16 @@ Smart quotes “work”, Greek Ω stays readable, and unsupported emoji 🚀 fal const exported = true \`\`\` -![Embedded image](/workspace/ws-1/files/image-1) +![Embedded image](${imageUrl}) + +Resized image ${repeatedParagraphs}` const buffer = await renderMarkdownPdf({ markdown, title: 'Export title', - images: new Map([['image-1', image]]), + images: new Map([[`key:${imageKey}`, image]]), }) expect(buffer.subarray(0, 4).toString()).toBe('%PDF') @@ -56,6 +78,46 @@ ${repeatedParagraphs}` const document = await PDFDocument.load(buffer) expect(document.getTitle()).toBe('Export title') expect(document.getPageCount()).toBeGreaterThan(1) + + const text = (await pdfPagesText(buffer)).join(' ') + expect(text).toContain('中文排版应该清晰易读') + expect(text).toContain('العربية') + // PDF extractors expose visually positioned Indic vowel marks before their base character. + expect(text).toMatch(/[\u0900-\u097f]{4,}/u) + expect(text).toContain('עברית') + expect(text).not.toContain('Image: Embedded image') + + const parsed = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }).promise + try { + let imagePaints = 0 + for (let pageNumber = 1; pageNumber <= parsed.numPages; pageNumber += 1) { + const operators = await (await parsed.getPage(pageNumber)).getOperatorList() + imagePaints += operators.fnArray.filter( + (operator) => + operator === OPS.paintImageXObject || operator === OPS.paintInlineImageXObject + ).length + } + expect(imagePaints).toBeGreaterThanOrEqual(2) + } finally { + await parsed.destroy() + } + }) + + it('keeps long table rows together and repeats the header across table pages', async () => { + const rows = Array.from( + { length: 90 }, + (_, index) => + `| Row ${index + 1} | Description ${index + 1} with enough text to exercise wrapping |` + ).join('\n') + const buffer = await renderMarkdownPdf({ + markdown: `# Table report\n\n| Name | Value |\n| --- | --- |\n${rows}`, + title: 'Table report', + }) + + const pages = await pdfPagesText(buffer) + const tablePages = pages.filter((page) => page.includes('Row ')) + expect(tablePages.length).toBeGreaterThan(1) + expect(tablePages.every((page) => page.includes('Name') && page.includes('Value'))).toBe(true) }) it('falls back instead of decoding an image above the pixel ceiling', async () => { @@ -66,10 +128,19 @@ ${repeatedParagraphs}` const buffer = await renderMarkdownPdf({ markdown: '![Too large](/api/files/view/image-1)', title: 'Bounded image', - images: new Map([['image-1', oversizedSvg]]), + images: new Map([['id:image-1', oversizedSvg]]), }) expect(buffer.subarray(0, 4).toString()).toBe('%PDF') expect((await PDFDocument.load(buffer)).getPageCount()).toBe(1) + expect((await pdfPagesText(buffer)).join(' ')).toContain('Image: Too large') + }) + + it('rejects a pathological number of document blocks before PDF layout', async () => { + const markdown = Array.from({ length: 3_001 }, (_, index) => `Paragraph ${index}`).join('\n\n') + + await expect(renderMarkdownPdf({ markdown, title: 'Too many blocks' })).rejects.toBeInstanceOf( + MarkdownPdfLimitError + ) }) }) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 9157d116c87..eb14fa5e6dd 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import type { ReactNode } from 'react' @@ -12,9 +13,11 @@ import { Text, View, } from '@react-pdf/renderer' -import { marked, type Token, type Tokens } from 'marked' +import type { JSONContent } from '@tiptap/core' import sharp from 'sharp' -import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +import { parseServerMarkdownToDoc } from '@/lib/collab-doc/server-markdown' +import { embeddedFileRefKey, extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' type PdfImage = { data: Buffer; format: 'png' } @@ -22,21 +25,80 @@ interface GlyphFont { hasGlyphForCodePoint(codePoint: number): boolean } -const FONT_DIR = join(process.cwd(), 'public', 'brand', 'fonts') -const GEIST_REGULAR = join(FONT_DIR, 'Geist-Regular.ttf') -const GEIST_MEDIUM = join(FONT_DIR, 'Geist-Medium.ttf') +interface PdfFont { + family: string + glyphs: GlyphFont +} + +const require = createRequire(import.meta.url) + +function resolveBrandFont(filename: string): string { + const candidates = [ + join(process.cwd(), 'public', 'brand', 'fonts', filename), + join(process.cwd(), 'apps', 'sim', 'public', 'brand', 'fonts', filename), + ] + const font = candidates.find(existsSync) + if (!font) throw new Error(`PDF font not found: ${filename}`) + return font +} + +function resolveDependencyFont(packageName: string, filename: string): string { + const relativePath = join(packageName, 'files', filename) + const candidates = [ + join(process.cwd(), 'node_modules', relativePath), + join(process.cwd(), '..', '..', 'node_modules', relativePath), + join(process.cwd(), 'apps', 'sim', 'node_modules', relativePath), + ] + const font = candidates.find(existsSync) + if (!font) throw new Error(`PDF dependency font not found: ${filename}`) + return font +} + +const GEIST_REGULAR = resolveBrandFont('Geist-Regular.ttf') +const GEIST_MEDIUM = resolveBrandFont('Geist-Medium.ttf') +const UNIFONT_REGULAR = resolveDependencyFont( + '@fontsource/unifont', + 'unifont-latin-400-normal.woff' +) +const NOTO_ARABIC = resolveDependencyFont( + '@fontsource/noto-sans-arabic', + 'noto-sans-arabic-arabic-400-normal.woff' +) +const NOTO_ARABIC_BOLD = resolveDependencyFont( + '@fontsource/noto-sans-arabic', + 'noto-sans-arabic-arabic-700-normal.woff' +) +const NOTO_DEVANAGARI = resolveDependencyFont( + '@fontsource/noto-sans-devanagari', + 'noto-sans-devanagari-devanagari-400-normal.woff' +) +const NOTO_DEVANAGARI_BOLD = resolveDependencyFont( + '@fontsource/noto-sans-devanagari', + 'noto-sans-devanagari-devanagari-700-normal.woff' +) +const NOTO_HEBREW = resolveDependencyFont( + '@fontsource/noto-sans-hebrew', + 'noto-sans-hebrew-hebrew-400-normal.woff' +) +const NOTO_HEBREW_BOLD = resolveDependencyFont( + '@fontsource/noto-sans-hebrew', + 'noto-sans-hebrew-hebrew-700-normal.woff' +) /** - * PDF images never render wider than the A4 content box, so retaining camera-resolution - * rasters only increases Sharp and React PDF work. The dimension matches the app's existing - * inline-image preparation ceiling; the aggregate budgets bound work across a document. + * PDF images never render wider than the A4 content box. These PDF-specific ceilings reject + * decompression bombs well below Sharp's broad application default, then bound normalized output. */ const MAX_PDF_IMAGE_DIMENSION = 1568 -const MAX_PDF_IMAGE_INPUT_PIXELS = 268_402_689 -const MAX_PDF_TOTAL_INPUT_PIXELS = 268_402_689 +const MAX_PDF_IMAGE_INPUT_PIXELS = 40_000_000 +const MAX_PDF_TOTAL_INPUT_PIXELS = 80_000_000 const MAX_PDF_TOTAL_OUTPUT_PIXELS = 25_000_000 const MAX_PDF_IMAGE_BYTES = 12 * 1024 * 1024 const MAX_PDF_TOTAL_IMAGE_BYTES = 32 * 1024 * 1024 +const MAX_PDF_DOCUMENT_NODES = 20_000 +const MAX_PDF_TOP_LEVEL_BLOCKS = 3_000 +const MAX_UNBREAKABLE_TABLE_HEIGHT = 620 +const PDF_TABLE_CONTENT_WIDTH = 499 Font.register({ family: 'Geist', @@ -47,10 +109,31 @@ Font.register({ { src: GEIST_MEDIUM, fontStyle: 'italic', fontWeight: 700 }, ], }) +function registerFallbackFont(family: string, src: string, boldSrc = src): void { + Font.register({ + family, + fonts: [ + { src, fontStyle: 'normal', fontWeight: 400 }, + { src, fontStyle: 'italic', fontWeight: 400 }, + { src: boldSrc, fontStyle: 'normal', fontWeight: 700 }, + { src: boldSrc, fontStyle: 'italic', fontWeight: 700 }, + ], + }) +} + +registerFallbackFont('NotoSansArabic', NOTO_ARABIC, NOTO_ARABIC_BOLD) +registerFallbackFont('NotoSansDevanagari', NOTO_DEVANAGARI, NOTO_DEVANAGARI_BOLD) +registerFallbackFont('NotoSansHebrew', NOTO_HEBREW, NOTO_HEBREW_BOLD) +registerFallbackFont('Unifont', UNIFONT_REGULAR) -const require = createRequire(import.meta.url) const { openSync } = require('fontkit') as { openSync(path: string): GlyphFont } const geistGlyphs = openSync(GEIST_REGULAR) +const staticFallbackFonts: PdfFont[] = [ + { family: 'NotoSansArabic', glyphs: openSync(NOTO_ARABIC) }, + { family: 'NotoSansDevanagari', glyphs: openSync(NOTO_DEVANAGARI) }, + { family: 'NotoSansHebrew', glyphs: openSync(NOTO_HEBREW) }, +] +const unifont: PdfFont = { family: 'Unifont', glyphs: openSync(UNIFONT_REGULAR) } export interface MarkdownPdfInput { markdown: string @@ -58,6 +141,29 @@ export interface MarkdownPdfInput { images?: ReadonlyMap } +export class MarkdownPdfLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'MarkdownPdfLimitError' + } +} + +interface MarkdownDocumentProps { + document: JSONContent + title: string + images: ReadonlyMap +} + +interface FontRun { + family: string + text: string +} + +interface TableChunk { + rows: JSONContent[] + unbreakable: boolean +} + const styles = StyleSheet.create({ page: { backgroundColor: '#ffffff', @@ -79,13 +185,14 @@ const styles = StyleSheet.create({ strong: { fontWeight: 700 }, emphasis: { fontStyle: 'italic' }, deleted: { textDecoration: 'line-through' }, + highlighted: { backgroundColor: '#fff3bf' }, inlineCode: { backgroundColor: '#f1f3f5', color: '#24292f', - fontFamily: 'Geist', fontSize: 9, }, link: { color: '#0969da', textDecoration: 'underline' }, + mention: { backgroundColor: '#f1f3f5', color: '#404040' }, blockquote: { borderLeftColor: '#b6bec8', borderLeftWidth: 2, @@ -99,7 +206,6 @@ const styles = StyleSheet.create({ borderRadius: 3, borderWidth: 0.5, color: '#24292f', - fontFamily: 'Geist', fontSize: 8.5, lineHeight: 1.35, marginBottom: 10, @@ -133,22 +239,95 @@ const styles = StyleSheet.create({ marginBottom: 9, padding: 8, }, - htmlFallback: { color: '#57606a', marginBottom: 9 }, + sourceFallback: { + backgroundColor: '#f6f8fa', + color: '#57606a', + fontSize: 8.5, + marginBottom: 9, + padding: 8, + }, }) -function safeText(value: string): string { - // React PDF's standard fonts can map a missing glyph to an unrelated visible character. - // Use the same bundled font for measurement and rendering, with an explicit readable fallback. - let safe = '' - for (const character of value) { +function fontRuns(value: string): FontRun[] { + const characters = Array.from(value, (character) => { const codePoint = character.codePointAt(0) - safe += codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) ? character : '?' + const fallback = + codePoint === undefined + ? undefined + : (staticFallbackFonts.find(({ glyphs }) => glyphs.hasGlyphForCodePoint(codePoint)) ?? + (unifont.glyphs.hasGlyphForCodePoint(codePoint) ? unifont : undefined)) + const family = + codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) + ? 'Geist' + : (fallback?.family ?? 'Unifont') + return { + family, + neutral: /^[\p{N}\p{P}\p{Z}\s]$/u.test(character), + text: family === 'Geist' || fallback ? character : '�', + } + }) + + for (const [index, character] of characters.entries()) { + if (character.family !== 'Geist' || !character.neutral) continue + + let previous = index - 1 + while (previous >= 0 && characters[previous].neutral) previous -= 1 + let next = index + 1 + while (next < characters.length && characters[next].neutral) next += 1 + const previousFamily = characters[previous]?.family + const nextFamily = characters[next]?.family + if (previousFamily && previousFamily !== 'Geist' && previousFamily === nextFamily) { + character.family = previousFamily + } else if (previousFamily && previousFamily !== 'Geist' && next >= characters.length) { + character.family = previousFamily + } + } + + const runs: FontRun[] = [] + for (const { family, text } of characters) { + const current = runs.at(-1) + if (current?.family === family) current.text += text + else runs.push({ family, text }) + } + return runs +} + +function renderText(value: string, keyPrefix: string): ReactNode[] { + return fontRuns(value).map((run, index) => + run.family === 'Geist' ? ( + run.text + ) : ( + + {run.text} + + ) + ) +} + +function nodeText(node: JSONContent): string { + if (typeof node.text === 'string') return node.text + return (node.content ?? []).map(nodeText).join('') +} + +function assertDocumentWithinLimits(document: JSONContent): void { + if ((document.content?.length ?? 0) > MAX_PDF_TOP_LEVEL_BLOCKS) { + throw new MarkdownPdfLimitError('This document has too many blocks to export as PDF.') + } + + let nodeCount = 0 + const visit = (node: JSONContent): void => { + nodeCount += 1 + if (nodeCount > MAX_PDF_DOCUMENT_NODES) { + throw new MarkdownPdfLimitError('This document is too complex to export as PDF.') + } + for (const child of node.content ?? []) visit(child) } - return safe + visit(document) } -function plainHtml(value: string): string { - return safeText(value.replace(/<[^>]*>/g, '').trim()) +function stringAttr(node: JSONContent, name: string): string | undefined { + const value = node.attrs?.[name] + return typeof value === 'string' ? value : undefined } function safeLink(href: string): string | undefined { @@ -160,159 +339,131 @@ function safeLink(href: string): string | undefined { } } -function embeddedImageId(href: string): string | undefined { - const ref = extractEmbeddedFileRef(href) - return ref && 'fileId' in ref ? ref.fileId : undefined -} +function renderInlineNode(node: JSONContent, key: string): ReactNode { + if (node.type === 'hardBreak') return '\n' + if (node.type === 'mention') { + return ( + + {renderText(stringAttr(node, 'label') ?? 'Mention', key)} + + ) + } + if (node.type === 'rawInlineHtml' || node.type === 'footnoteRef') { + return ( + + {renderText(nodeText(node), key)} + + ) + } + if (node.type !== 'text') return renderText(nodeText(node), key) -function renderInline(tokens: Token[], keyPrefix: string): ReactNode[] { - return tokens.map((token, index) => { - const key = `${keyPrefix}-${index}` - switch (token.type) { - case 'text': - return token.tokens?.length ? renderInline(token.tokens, key) : safeText(token.text) - case 'escape': - return safeText(token.text) - case 'strong': { - const strong = token as Tokens.Strong - return ( - - {renderInline(strong.tokens, key)} - - ) - } - case 'em': { - const emphasis = token as Tokens.Em - return ( - - {renderInline(emphasis.tokens, key)} - - ) - } - case 'del': { - const deleted = token as Tokens.Del - return ( - - {renderInline(deleted.tokens, key)} - - ) - } - case 'codespan': - return ( - - {safeText(token.text)} - - ) - case 'br': - return '\n' - case 'link': { - const link = token as Tokens.Link - const href = safeLink(link.href) - const content = renderInline(link.tokens, key) - return href ? ( - - {content} - - ) : ( - - {content} - - ) - } - case 'image': - return safeText(token.text || token.href) - case 'html': - return plainHtml(token.text) - case 'checkbox': - return token.checked ? '[x] ' : '[ ] ' - default: - return 'text' in token && typeof token.text === 'string' ? safeText(token.text) : '' + const marks = node.marks ?? [] + const textStyles: Array< + | typeof styles.strong + | typeof styles.emphasis + | typeof styles.deleted + | typeof styles.inlineCode + | typeof styles.highlighted + > = [] + for (const mark of marks) { + switch (mark.type) { + case 'bold': + textStyles.push(styles.strong) + break + case 'italic': + textStyles.push(styles.emphasis) + break + case 'strike': + textStyles.push(styles.deleted) + break + case 'code': + textStyles.push(styles.inlineCode) + break + case 'highlight': + textStyles.push(styles.highlighted) + break } - }) + } + const content = renderText(node.text ?? '', key) + const linkMark = marks.find((mark) => mark.type === 'link') + const href = typeof linkMark?.attrs?.href === 'string' ? safeLink(linkMark.attrs.href) : undefined + if (href) { + return ( + + {content} + + ) + } + return textStyles.length > 0 ? ( + + {content} + + ) : ( + content + ) } -function directImage(token: Token): Tokens.Image | undefined { - if (token.type === 'image') return token as Tokens.Image - if (token.type === 'link') { - const link = token as Tokens.Link - if (link.tokens.length === 1 && link.tokens[0]?.type === 'image') { - return link.tokens[0] as Tokens.Image - } - } - return undefined +function renderInline(keyPrefix: string, nodes: JSONContent[] = []): ReactNode[] { + return nodes.map((node, index) => renderInlineNode(node, `${keyPrefix}-${index}`)) } -function renderImage(token: Tokens.Image, images: ReadonlyMap, key: string) { - const id = embeddedImageId(token.href) - const image = id ? images.get(id) : undefined +function renderImage( + node: JSONContent, + images: ReadonlyMap, + key: string +): ReactNode { + const src = stringAttr(node, 'src') ?? '' + const ref = extractEmbeddedFileRef(src) + const image = ref ? images.get(embeddedFileRefKey(ref)) : undefined if (!image) { + const alt = stringAttr(node, 'alt') return ( - {token.text ? `Image: ${safeText(token.text)}` : 'Image unavailable'} + {renderText(alt ? `Image: ${alt}` : 'Image unavailable', key)} ) } + + const requestedWidth = Number(stringAttr(node, 'width')) + const width = Number.isFinite(requestedWidth) + ? Math.min(Math.max(requestedWidth, 1), PDF_TABLE_CONTENT_WIDTH) + : undefined return ( - + ) } -function renderParagraph( - tokens: Token[], +function renderList( + node: JSONContent, images: ReadonlyMap, - keyPrefix: string -): ReactNode[] { - const output: ReactNode[] = [] - let inline: Token[] = [] - - const flushInline = () => { - if (inline.length === 0) return - output.push( - - {renderInline(inline, `${keyPrefix}-inline-${output.length}`)} - - ) - inline = [] - } - - for (const token of tokens) { - const image = directImage(token) - if (!image) { - inline.push(token) - continue - } - flushInline() - output.push(renderImage(image, images, `${keyPrefix}-image-${output.length}`)) - } - flushInline() - return output -} - -function renderList(token: Tokens.List, images: ReadonlyMap, key: string) { - const start = typeof token.start === 'number' ? token.start : 1 + key: string +): ReactNode { + const ordered = node.type === 'orderedList' + const task = node.type === 'taskList' + const start = typeof node.attrs?.start === 'number' ? node.attrs.start : 1 return ( - {token.items.map((item, index) => { - const marker = item.task - ? item.checked + {(node.content ?? []).map((item, index) => { + const marker = task + ? item.attrs?.checked ? '[x]' : '[ ]' - : token.ordered + : ordered ? `${start + index}.` : '-' return ( {marker} - {item.tokens.map((itemToken, tokenIndex) => - itemToken.type === 'text' ? ( - - {renderInline(itemToken.tokens ?? [itemToken], `${key}-${index}-${tokenIndex}`)} + {(item.content ?? []).map((child, childIndex) => + child.type === 'paragraph' ? ( + + {renderInline(`${key}-${index}-${childIndex}`, child.content)} ) : ( - renderBlock(itemToken, images, `${key}-${index}-${tokenIndex}`) + renderBlock(child, images, `${key}-${index}-${childIndex}`) ) )} @@ -323,103 +474,154 @@ function renderList(token: Tokens.List, images: ReadonlyMap, k ) } -function renderTable(token: Tokens.Table, key: string) { - const row = (cells: Tokens.TableCell[], rowKey: string, header: boolean) => ( - - {cells.map((cell, index) => ( +function estimateTableRowHeight(row: JSONContent, columnCount: number): number { + const columnWidth = PDF_TABLE_CONTENT_WIDTH / Math.max(columnCount, 1) + const charactersPerLine = Math.max(8, Math.floor(columnWidth / 4.8)) + const lines = Math.max( + 1, + ...(row.content ?? []).map((cell) => + Math.ceil(Math.max(nodeText(cell).length, 1) / charactersPerLine) + ) + ) + return 10 + lines * 12.5 +} + +function chunkTableRows(header: JSONContent, rows: JSONContent[]): TableChunk[] { + const columnCount = header.content?.length ?? rows[0]?.content?.length ?? 1 + const headerHeight = estimateTableRowHeight(header, columnCount) + const chunks: TableChunk[] = [] + let current: JSONContent[] = [] + let currentHeight = headerHeight + + const flush = () => { + if (current.length === 0) return + chunks.push({ rows: current, unbreakable: currentHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }) + current = [] + currentHeight = headerHeight + } + + for (const row of rows) { + const rowHeight = estimateTableRowHeight(row, columnCount) + if (current.length > 0 && currentHeight + rowHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() + current.push(row) + currentHeight += rowHeight + if (rowHeight + headerHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() + } + flush() + return chunks +} + +function renderTableRow(row: JSONContent, key: string, header: boolean): ReactNode { + return ( + + {(row.content ?? []).map((cell, index) => ( - {renderInline(cell.tokens, `${rowKey}-${index}`)} + {(cell.content ?? []).map((child, childIndex) => ( + + {childIndex > 0 ? '\n' : null} + {renderInline(`${key}-${index}-${childIndex}`, child.content)} + + ))} ))} ) +} - return ( - - {row(token.header, `${key}-header`, true)} - {token.rows.map((cells, index) => row(cells, `${key}-row-${index}`, false))} +function renderTable(node: JSONContent, key: string): ReactNode { + const rows = (node.content ?? []).filter((child) => child.type === 'tableRow') + if (rows.length === 0) return null + const firstRow = rows[0] + const hasHeader = firstRow.content?.some((cell) => cell.type === 'tableHeader') ?? false + const header = hasHeader ? firstRow : { type: 'tableRow', content: [] } + const bodyRows = hasHeader ? rows.slice(1) : rows + const chunks = hasHeader + ? chunkTableRows(header, bodyRows) + : [{ rows: bodyRows, unbreakable: false }] + + return chunks.map((chunk, index) => ( + + {hasHeader ? renderTableRow(header, `${key}-${index}-header`, true) : null} + {chunk.rows.map((row, rowIndex) => + renderTableRow(row, `${key}-${index}-row-${rowIndex}`, false) + )} - ) + )) } -function renderBlock(token: Token, images: ReadonlyMap, key: string): ReactNode { - switch (token.type) { - case 'space': - case 'def': - return null +function renderBlock( + node: JSONContent, + images: ReadonlyMap, + key: string +): ReactNode { + switch (node.type) { + case 'paragraph': + return ( + + {renderInline(key, node.content)} + + ) case 'heading': { - const heading = token as Tokens.Heading + const level = typeof node.attrs?.level === 'number' ? node.attrs.level : 1 const headingStyle = [styles.h1, styles.h2, styles.h3, styles.h4, styles.h5, styles.h6][ - Math.min(Math.max(heading.depth, 1), 6) - 1 + Math.min(Math.max(level, 1), 6) - 1 ] return ( - {renderInline(heading.tokens, key)} + {renderInline(key, node.content)} ) } - case 'paragraph': { - const paragraph = token as Tokens.Paragraph - return {renderParagraph(paragraph.tokens, images, key)} - } - case 'text': + case 'bulletList': + case 'orderedList': + case 'taskList': + return renderList(node, images, key) + case 'blockquote': return ( - - {renderInline(token.tokens ?? [token], key)} - + + {(node.content ?? []).map((child, index) => + renderBlock(child, images, `${key}-${index}`) + )} + ) - case 'code': + case 'codeBlock': return ( - {safeText(token.text)} + {renderText(nodeText(node), key)} ) - case 'blockquote': { - const blockquote = token as Tokens.Blockquote - return ( - - {blockquote.tokens.map((child, index) => renderBlock(child, images, `${key}-${index}`))} - - ) - } - case 'list': - return renderList(token as Tokens.List, images, key) case 'table': - return renderTable(token as Tokens.Table, key) - case 'hr': + return renderTable(node, key) + case 'horizontalRule': return - case 'html': { - const text = plainHtml(token.text) - return text ? ( - - {text} + case 'image': + return renderImage(node, images, key) + case 'rawHtmlBlock': + case 'footnoteDef': + return ( + + {renderText(nodeText(node), key)} - ) : null - } - default: - return 'text' in token && typeof token.text === 'string' ? ( + ) + default: { + const text = nodeText(node) + return text ? ( - {safeText(token.text)} + {renderText(text, key)} ) : null + } } } -interface MarkdownDocumentProps { - markdown: string - title: string - images: ReadonlyMap -} - -function MarkdownDocument({ markdown, title, images }: MarkdownDocumentProps) { - const tokens = marked.lexer(markdown, { gfm: true }) +function MarkdownDocument({ document, title, images }: MarkdownDocumentProps) { return ( - + - {tokens.map((token, index) => renderBlock(token, images, `block-${index}`))} + {(document.content ?? []).map((node, index) => renderBlock(node, images, `block-${index}`))} ) @@ -433,9 +635,12 @@ async function normalizeImages( let totalOutputPixels = 0 let totalImageBytes = 0 - for (const [id, buffer] of images) { + for (const [imageKey, buffer] of images) { try { - const pipeline = sharp(buffer, { limitInputPixels: MAX_PDF_IMAGE_INPUT_PIXELS }) + const pipeline = sharp(buffer, { + limitInputPixels: MAX_PDF_IMAGE_INPUT_PIXELS, + sequentialRead: true, + }) const metadata = await pipeline.metadata() if (!metadata.width || !metadata.height) continue @@ -477,7 +682,7 @@ async function normalizeImages( totalOutputPixels += outputPixels totalImageBytes += data.length - normalized.set(id, { data, format: 'png' }) + normalized.set(imageKey, { data, format: 'png' }) } catch { // Keep the PDF usable when an otherwise downloadable attachment is not a renderable image. } @@ -491,7 +696,10 @@ export async function renderMarkdownPdf({ images = new Map(), }: MarkdownPdfInput): Promise { const normalizedImages = await normalizeImages(images) + const { body } = splitFrontmatter(markdown) + const document = parseServerMarkdownToDoc(body) + assertDocumentWithinLimits(document) return renderToBuffer( - + ) } diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index ed85e2e2f59..5f19013ff16 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -11,21 +11,23 @@ const { mockGetFileMetadataById, mockVerifyFileAccess, mockDownloadFile, - mockExtractEmbeddedImageIds, + mockResolveWorkspaceInlineImage, mockRenderMarkdownPdf, mockEnforceUserRateLimit, mockRecordAudit, mockCaptureServerEvent, + MockMarkdownPdfLimitError, } = vi.hoisted(() => ({ mockCheckAuth: vi.fn(), mockGetFileMetadataById: vi.fn(), mockVerifyFileAccess: vi.fn(), mockDownloadFile: vi.fn(), - mockExtractEmbeddedImageIds: vi.fn(), + mockResolveWorkspaceInlineImage: vi.fn(), mockRenderMarkdownPdf: vi.fn(), mockEnforceUserRateLimit: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), + MockMarkdownPdfLimitError: class extends Error {}, })) vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth })) @@ -34,11 +36,12 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) -vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ - extractEmbeddedImageIds: mockExtractEmbeddedImageIds, +vi.mock('@/lib/uploads/server/inline-image', () => ({ + resolveWorkspaceInlineImage: mockResolveWorkspaceInlineImage, })) vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ renderMarkdownPdf: mockRenderMarkdownPdf, + MarkdownPdfLimitError: MockMarkdownPdfLimitError, })) vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, @@ -66,38 +69,40 @@ function request(format?: 'pdf') { ) } -function assetRecord(id: string, size: number) { +function inlineImage(id: string, size = 1 * MB) { return { - id, key: `workspace/ws-1/${id}`, - originalName: `${id}.png`, + filename: id.endsWith('.png') ? id : `${id}.png`, contentType: 'image/png', - context: 'workspace', size, - workspaceId: 'ws-1', } } +function markdownWithIds(...ids: string[]): Buffer { + return Buffer.from(ids.map((id) => `![${id}](/api/files/view/${id})`).join('\n')) +} + describe('markdown export bundling', () => { beforeEach(() => { vi.clearAllMocks() mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' }) mockVerifyFileAccess.mockResolvedValue(true) - mockGetFileMetadataById.mockImplementation(async (id: string) => - id === DOC_ID - ? { - id: DOC_ID, - key: 'workspace/ws-1/doc.md', - originalName: 'doc.md', - contentType: 'text/markdown', - context: 'workspace', - size: 1024, - workspaceId: 'ws-1', - } - : assetRecord(id, 1 * MB) + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId?: string; key?: string }) => { + const id = ref.fileId ?? ref.key?.split('/').at(-1) ?? 'image' + return inlineImage(id) + } ) mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) - mockExtractEmbeddedImageIds.mockReturnValue([]) mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) mockEnforceUserRateLimit.mockResolvedValue(null) }) @@ -145,11 +150,22 @@ describe('markdown export bundling', () => { expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) + it('returns a clear rejection when the parsed document exceeds renderer limits', async () => { + mockRenderMarkdownPdf.mockRejectedValue( + new MockMarkdownPdfLimitError('This document is too complex to export as PDF.') + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('too complex') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + it('passes only authorized, readable embedded images to the PDF renderer', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['good', 'secret', 'broken']) mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return Buffer.from('![image](/api/files/view/good)') + if (key.endsWith('doc.md')) return markdownWithIds('good', 'secret', 'broken') if (key.endsWith('broken')) throw new Error('storage down') return Buffer.from('png-bytes') }) @@ -158,12 +174,30 @@ describe('markdown export bundling', () => { expect(response.status).toBe(200) const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map - expect(Array.from(images.keys())).toEqual(['good']) - expect(images.get('good')).toEqual(Buffer.from('png-bytes')) + expect(Array.from(images.keys())).toEqual(['id:good']) + expect(images.get('id:good')).toEqual(Buffer.from('png-bytes')) + }) + + it('resolves the key-based image URL emitted by the Files editor', async () => { + const key = 'workspace/ws-1/editor-image.png' + mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => + requestedKey.endsWith('doc.md') + ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) + : Buffer.from('png-bytes') + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceInlineImage).toHaveBeenCalledWith('ws-1', { key }) + const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map + expect(images.get(`key:${key}`)).toEqual(Buffer.from('png-bytes')) }) it('records an image-containing PDF as one downloaded file', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') + ) await GET(request('pdf'), context) @@ -181,7 +215,9 @@ describe('markdown export bundling', () => { }) it('keeps image-containing ZIP telemetry bulk', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') + ) await GET(request(), context) @@ -213,19 +249,9 @@ describe('markdown export bundling', () => { }) it('rejects on declared asset bytes before downloading any of them', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c']) - mockGetFileMetadataById.mockImplementation(async (id: string) => - id === DOC_ID - ? { - id: DOC_ID, - key: 'workspace/ws-1/doc.md', - originalName: 'doc.md', - contentType: 'text/markdown', - context: 'workspace', - size: 1024, - workspaceId: 'ws-1', - } - : assetRecord(id, 100 * MB) + mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b', 'c')) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 100 * MB) ) const response = await GET(request(), context) @@ -238,8 +264,9 @@ describe('markdown export bundling', () => { it('counts the document body against the export limit, not just its assets', async () => { // Assets alone sit under the cap; the body is what carries the bundle over it. - mockExtractEmbeddedImageIds.mockReturnValue(['a']) - mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB)) + const body = Buffer.alloc(250 * MB) + markdownWithIds('a').copy(body) + mockDownloadFile.mockResolvedValue(body) const response = await GET(request(), context) @@ -248,8 +275,6 @@ describe('markdown export bundling', () => { }) it('caps the document body read rather than loading it unbounded', async () => { - mockExtractEmbeddedImageIds.mockReturnValue([]) - await GET(request(), context) const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) @@ -276,7 +301,6 @@ describe('markdown export bundling', () => { }) it('reports an oversized body as a size rejection, not a server error', async () => { - mockExtractEmbeddedImageIds.mockReturnValue([]) mockDownloadFile.mockRejectedValue( new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) ) @@ -289,7 +313,9 @@ describe('markdown export bundling', () => { }) it('caps each asset download rather than trusting its declared size', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') + ) await GET(request(), context) @@ -300,7 +326,9 @@ describe('markdown export bundling', () => { }) it('uses a smaller per-asset limit for PDF rendering', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') + ) await GET(request('pdf'), context) @@ -311,19 +339,9 @@ describe('markdown export bundling', () => { }) it('rejects PDF source material above its aggregate input limit', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b']) - mockGetFileMetadataById.mockImplementation(async (id: string) => - id === DOC_ID - ? { - id: DOC_ID, - key: 'workspace/ws-1/doc.md', - originalName: 'doc.md', - contentType: 'text/markdown', - context: 'workspace', - size: 1024, - workspaceId: 'ws-1', - } - : assetRecord(id, 30 * MB) + mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b')) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 30 * MB) ) const response = await GET(request('pdf'), context) @@ -334,10 +352,37 @@ describe('markdown export bundling', () => { expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) + it('enforces the aggregate PDF budget against downloaded bytes, not only metadata', async () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f'] + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds(...ids) : Buffer.alloc(10 * MB) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map + expect(images.size).toBe(4) + }) + + it('rewrites the editor key URL when producing a Markdown asset ZIP', async () => { + const key = 'workspace/ws-1/editor-image.png' + mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => + requestedKey.endsWith('doc.md') + ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) + : Buffer.from('png-bytes') + ) + + const response = await GET(request(), context) + + const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) + expect(await zip.file('doc.md')?.async('string')).toBe('![image](./assets/editor-image.png)') + expect(zip.file('assets/editor-image.png')).not.toBeNull() + }) + it('drops an unreadable asset instead of failing the whole export', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad']) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return Buffer.from('# Doc\n![x](/api/files/view/good)\n') + if (key.endsWith('doc.md')) return markdownWithIds('good', 'bad') if (key.endsWith('bad')) throw new Error('storage down') return Buffer.from('png-bytes') }) @@ -351,8 +396,10 @@ describe('markdown export bundling', () => { }) it('skips an asset the caller cannot read', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['secret']) mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('secret') : Buffer.from('asset') + ) const response = await GET(request(), context) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index a3ea5e8c110..a92ad0fa9bc 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -8,7 +8,6 @@ import { NextResponse } from 'next/server' import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' @@ -18,10 +17,16 @@ import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { getServeStoragePrefix } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' +import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' import { getFileMetadataById } from '@/lib/uploads/server/metadata' +import { + embeddedFileRefKey, + extractEmbeddedFileRefs, + type ResolvedEmbeddedFileRef, + replaceEmbeddedFileRefs, +} from '@/lib/uploads/utils/embedded-image-ref' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' -import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') @@ -37,7 +42,7 @@ const logger = createLogger('FilesExportAPI') */ const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 -/** Matches the editor's p99-plus document ceiling; larger files would create an unbounded PDF layout tree. */ +/** PDF-specific document ceiling that bounds parser and layout-tree work. */ const MAX_PDF_MARKDOWN_BYTES = 256 * 1024 const MAX_PDF_ASSET_BYTES = 10 * 1024 * 1024 const MAX_PDF_TOTAL_SOURCE_BYTES = 50 * 1024 * 1024 @@ -65,13 +70,13 @@ function safeFilename(name: string): string { .replace(/[\r\n\t]/g, '') } -function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { +function deduplicatedFilename(preferred: string, existing: Set): string { if (!existing.has(preferred)) return preferred const ext = path.extname(preferred) const base = path.basename(preferred, ext) - const short = `${base}_${imageId.slice(0, 8)}${ext}` - if (!existing.has(short)) return short - return `${base}_${imageId}${ext}` + let suffix = 2 + while (existing.has(`${base}_${suffix}${ext}`)) suffix += 1 + return `${base}_${suffix}${ext}` } export const GET = withRouteHandler( @@ -184,18 +189,33 @@ export const GET = withRouteHandler( } let mdContent = mdBuffer.toString('utf-8') - const imageIds = extractEmbeddedImageIds(mdContent) + const { keys: imageKeys, ids: imageIds } = extractEmbeddedFileRefs(mdContent) + const imageRefs: ResolvedEmbeddedFileRef[] = [ + ...imageKeys.map((key) => ({ key })), + ...imageIds.map((fileId) => ({ fileId })), + ] logger.info('Exporting markdown', { id, format: format ?? 'source', - imageCount: imageIds.length, + imageCount: imageRefs.length, }) const respondWithPdf = async (images: ReadonlyMap) => { + const { MarkdownPdfLimitError, renderMarkdownPdf } = await import( + '@/app/api/files/export/[id]/markdown-pdf' + ) const title = record.originalName.replace(/\.(?:md|markdown)$/i, '') const pdfName = safeFilename(`${title}.pdf`) - const pdfBuffer = await renderMarkdownPdf({ markdown: mdContent, title, images }) + let pdfBuffer: Buffer + try { + pdfBuffer = await renderMarkdownPdf({ markdown: mdContent, title, images }) + } catch (error) { + if (error instanceof MarkdownPdfLimitError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } auditExport('pdf', images.size) return new NextResponse(new Uint8Array(pdfBuffer), { status: 200, @@ -207,7 +227,7 @@ export const GET = withRouteHandler( }) } - if (imageIds.length === 0) { + if (imageRefs.length === 0) { if (format === 'pdf') return respondWithPdf(new Map()) const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') @@ -225,15 +245,15 @@ export const GET = withRouteHandler( // Metadata first: declared sizes bound the download before a byte is read, and the // authorization check costs nothing to run here. const assetTargets = ( - await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => { + await mapWithConcurrency(imageRefs, MATERIALIZE_CONCURRENCY, async (ref) => { try { - const imgRecord = await getFileMetadataById(imageId) - if (!imgRecord) return null - if (!(await verifyFileAccess(imgRecord.key, userId))) return null - return { imageId, record: imgRecord } + if (!record.workspaceId) return null + const image = await resolveWorkspaceInlineImage(record.workspaceId, ref) + if (!image || !(await verifyFileAccess(image.key, userId))) return null + return { imageKey: embeddedFileRefKey(ref), image } } catch (error) { logger.warn('Failed to resolve asset for export', { - imageId, + imageRef: embeddedFileRefKey(ref), error: toError(error).message, }) return null @@ -244,7 +264,7 @@ export const GET = withRouteHandler( // The body counts against the same budget as its assets — the zip holds both, so a // limit that measured only the attachments would not describe the archive produced. const bundleBytes = - mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0) + mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.image.size, 0) const bundleLimit = format === 'pdf' ? MAX_PDF_TOTAL_SOURCE_BYTES : MAX_EXPORT_TOTAL_BYTES if (bundleBytes > bundleLimit) { return NextResponse.json( @@ -255,22 +275,33 @@ export const GET = withRouteHandler( ) } + let actualBundleBytes = mdBuffer.length const fetched = await mapWithConcurrency( assetTargets, - MATERIALIZE_CONCURRENCY, - async ({ imageId, record: imgRecord }) => { + // PDF assets stay sequential so the actual-byte budget also bounds peak retained buffers; + // ZIP keeps the existing shared materialization concurrency. + format === 'pdf' ? 1 : MATERIALIZE_CONCURRENCY, + async ({ imageKey, image }) => { try { const buffer = await downloadFile({ - key: imgRecord.key, - context: imgRecord.context as StorageContext, + key: image.key, + context: 'workspace', maxBytes: format === 'pdf' ? MAX_PDF_ASSET_BYTES : MAX_EXPORT_ASSET_BYTES, }) - return { imageId, originalName: imgRecord.originalName, buffer } + if (actualBundleBytes + buffer.length > bundleLimit) { + logger.warn('Skipping asset that exceeds the actual export byte budget', { + imageRef: imageKey, + bundleLimit, + }) + return null + } + actualBundleBytes += buffer.length + return { imageKey, originalName: image.filename, buffer } } catch (error) { // A single unreadable or oversized asset drops out of the bundle rather than // failing the whole export; the markdown keeps its original link. logger.warn('Failed to fetch asset for export', { - imageId, + imageRef: imageKey, error: toError(error).message, }) return null @@ -283,28 +314,23 @@ export const GET = withRouteHandler( for (const result of fetched) { if (!result) continue - const { imageId, originalName, buffer } = result + const { imageKey, originalName, buffer } = result const preferred = safeFilename(originalName) - const filename = deduplicatedFilename(preferred, usedFilenames, imageId) + const filename = deduplicatedFilename(preferred, usedFilenames) usedFilenames.add(filename) - assetMap.set(imageId, { filename, buffer }) + assetMap.set(imageKey, { filename, buffer }) } if (format === 'pdf') { return respondWithPdf( - new Map(Array.from(assetMap, ([imageId, asset]) => [imageId, asset.buffer])) + new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, asset.buffer])) ) } - for (const [imageId, asset] of assetMap) { - const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const replacement = `./assets/${asset.filename}` - // Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app - // `/workspace//files/` 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) - } + mdContent = replaceEmbeddedFileRefs( + mdContent, + new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, `./assets/${asset.filename}`])) + ) const zip = new JSZip() zip.file(safeFilename(record.originalName), mdContent) diff --git a/apps/sim/lib/collab-doc/server-markdown.ts b/apps/sim/lib/collab-doc/server-markdown.ts new file mode 100644 index 00000000000..62f519d1331 --- /dev/null +++ b/apps/sim/lib/collab-doc/server-markdown.ts @@ -0,0 +1,22 @@ +import type { JSONContent } from '@tiptap/core' +import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' + +/** + * Installs the minimal DOM globals needed by the canonical TipTap Markdown engine in a server + * process. The single jsdom window is reused for every parse/serialize call in that process. + */ +export function ensureServerMarkdownDom(): void { + if (typeof window !== 'undefined' && typeof document !== 'undefined') return + const { JSDOM } = require('jsdom') as typeof import('jsdom') + const { window: jsdomWindow } = new JSDOM('') + const globals = globalThis as unknown as Record + globals.window = jsdomWindow + globals.document = jsdomWindow.document + globals.navigator ??= jsdomWindow.navigator +} + +/** Parse Markdown with the exact extension set and schema used by the Files editor. */ +export function parseServerMarkdownToDoc(markdown: string): JSONContent { + ensureServerMarkdownDom() + return parseMarkdownToDoc(markdown) +} diff --git a/apps/sim/lib/uploads/server/inline-image.test.ts b/apps/sim/lib/uploads/server/inline-image.test.ts index ba774a3e8f1..db5eb6da131 100644 --- a/apps/sim/lib/uploads/server/inline-image.test.ts +++ b/apps/sim/lib/uploads/server/inline-image.test.ts @@ -21,6 +21,7 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', type: 'image/png', name: 'x.png', + size: 123, }) const out = await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' }) expect(mockGetWorkspaceFile).toHaveBeenCalledWith('ws-1', 'wf_a') @@ -28,6 +29,7 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', contentType: 'image/png', filename: 'x.png', + size: 123, }) }) @@ -42,12 +44,14 @@ describe('resolveWorkspaceInlineImage', () => { workspaceId: 'ws-1', contentType: 'image/png', originalName: 'x.png', + size: 456, }) 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', + size: 456, }) }) diff --git a/apps/sim/lib/uploads/server/inline-image.ts b/apps/sim/lib/uploads/server/inline-image.ts index b44b9e08e4a..918e2a9b54d 100644 --- a/apps/sim/lib/uploads/server/inline-image.ts +++ b/apps/sim/lib/uploads/server/inline-image.ts @@ -15,6 +15,7 @@ export interface ResolvedInlineImage { key: string contentType: string filename: string + size: number } /** @@ -30,12 +31,19 @@ export async function resolveWorkspaceInlineImage( ): Promise { if (ref.fileId) { const file = await getWorkspaceFile(workspaceId, ref.fileId) - return file ? { key: file.key, contentType: file.type, filename: file.name } : null + return file + ? { key: file.key, contentType: file.type, filename: file.name, size: file.size } + : 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 { + key: record.key, + contentType: record.contentType, + filename: record.originalName, + size: record.size, + } } return null } diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts index bec0f9e936b..e13bf554052 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + embeddedFileRefKey, extractEmbeddedFileRef, extractEmbeddedFileRefs, + replaceEmbeddedFileRefs, } from '@/lib/uploads/utils/embedded-image-ref' const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' @@ -31,6 +33,13 @@ describe('extractEmbeddedFileRef', () => { }) }) +describe('embeddedFileRefKey', () => { + it('keeps storage keys and file ids in distinct map namespaces', () => { + expect(embeddedFileRefKey({ key: KEY })).toBe(`key:${KEY}`) + expect(embeddedFileRefKey({ fileId: 'wf_abc' })).toBe('id:wf_abc') + }) +}) + describe('extractEmbeddedFileRefs', () => { it('collects de-duplicated keys and ids from a document via the shared parser', () => { const content = ` @@ -39,6 +48,7 @@ describe('extractEmbeddedFileRefs', () => { ![c](/workspace/W1/files/4bdaf6c4-072e-464e-891d-b6af3b5fe2cc) ![dup](/api/files/serve/s3/${ENCODED}) ![ext](https://cdn.example.com/x.png) + ![absolute](https://sim.ai/api/files/view/wf_external) ![pub](/api/files/serve/profile-pictures%2Fu1%2Favatar.png) ` const { keys, ids } = extractEmbeddedFileRefs(content) @@ -59,3 +69,25 @@ describe('extractEmbeddedFileRefs', () => { expect(k.length + d.length).toBe(50) }) }) + +describe('replaceEmbeddedFileRefs', () => { + it('rewrites key and id spellings without touching absolute URLs', () => { + const content = [ + `![key](/api/files/serve/${ENCODED}?context=workspace)`, + '![id](/api/files/view/wf_abc)', + '![absolute](https://sim.ai/api/files/view/wf_abc)', + ].join('\n') + const replacements = new Map([ + [`key:${KEY}`, './assets/key.png'], + ['id:wf_abc', './assets/id.png'], + ]) + + expect(replaceEmbeddedFileRefs(content, replacements)).toBe( + [ + '![key](./assets/key.png)', + '![id](./assets/id.png)', + '![absolute](https://sim.ai/api/files/view/wf_abc)', + ].join('\n') + ) + }) +}) diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts index 7e780362ea5..c4dfea38ab8 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -10,17 +10,24 @@ /** 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 +export type ResolvedEmbeddedFileRef = Exclude /** 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}. + * path. A required start/delimiter prevents matching the path portion of an absolute URL; the captured + * run stops at Markdown/HTML delimiters so authoritative parsing is left to + * {@link extractEmbeddedFileRef}. */ const EMBED_URL_RE = - /(?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>?]*/g + /(^|[\s("'<>])((?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>]*)/gm + +/** Stable map key shared by routes and renderers for either supported reference spelling. */ +export function embeddedFileRefKey(ref: ResolvedEmbeddedFileRef): string { + return 'key' in ref ? `key:${ref.key}` : `id:${ref.fileId}` +} /** * Parse a single embed `src` into the workspace file it references, normalizing the spellings the @@ -65,7 +72,7 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: const keys = new Set() const ids = new Set() for (const match of content.matchAll(EMBED_URL_RE)) { - const ref = extractEmbeddedFileRef(match[0]) + const ref = extractEmbeddedFileRef(match[2]) if (!ref) continue if ('key' in ref) keys.add(ref.key) else ids.add(ref.fileId) @@ -73,3 +80,15 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: } return { keys: [...keys], ids: [...ids] } } + +/** Rewrite authorized embedded references while leaving external and unmatched URLs untouched. */ +export function replaceEmbeddedFileRefs( + content: string, + replacements: ReadonlyMap +): string { + return content.replace(EMBED_URL_RE, (_match, prefix: string, candidate: string) => { + const ref = extractEmbeddedFileRef(candidate) + const replacement = ref ? replacements.get(embeddedFileRefKey(ref)) : undefined + return `${prefix}${replacement ?? candidate}` + }) +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index fd95ec30a4a..39314f52420 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -168,6 +168,7 @@ const nextConfig: NextConfig = { '/api/internal/file-doc/seed': ['./node_modules/jsdom/**/*'], '/api/internal/file-doc/merge': ['./node_modules/jsdom/**/*'], '/api/internal/file-doc/persist': ['./node_modules/jsdom/**/*'], + '/api/files/export/*': ['./node_modules/jsdom/**/*'], /** * No `sharp`/`@img` entries: these globs resolve against apps/sim while both hoist to the * monorepo root, so they matched nothing. docker/app.Dockerfile copies them instead. diff --git a/apps/sim/package.json b/apps/sim/package.json index 2814cb16910..5f8687e20a1 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -72,6 +72,10 @@ "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", "@floating-ui/dom": "1.7.6", + "@fontsource/noto-sans-arabic": "5.3.0", + "@fontsource/noto-sans-devanagari": "5.3.0", + "@fontsource/noto-sans-hebrew": "5.3.0", + "@fontsource/unifont": "5.3.0", "@google-cloud/storage": "7.21.0", "@google/genai": "2.13.0", "@hookform/resolvers": "5.2.2", @@ -190,7 +194,6 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", - "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", diff --git a/bun.lock b/bun.lock index 099f6fab5fb..3ca45640f4d 100644 --- a/bun.lock +++ b/bun.lock @@ -175,6 +175,10 @@ "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", "@floating-ui/dom": "1.7.6", + "@fontsource/noto-sans-arabic": "5.3.0", + "@fontsource/noto-sans-devanagari": "5.3.0", + "@fontsource/noto-sans-hebrew": "5.3.0", + "@fontsource/unifont": "5.3.0", "@google-cloud/storage": "7.21.0", "@google/genai": "2.13.0", "@hookform/resolvers": "5.2.2", @@ -293,7 +297,6 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", - "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", @@ -1155,6 +1158,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource/noto-sans-arabic": ["@fontsource/noto-sans-arabic@5.3.0", "", {}, "sha512-i3t6GR0LOReyJVEk+YfYCnxv53wuIelg7Y9NGNvOq4Diq/EP0YszaGXkQwfQNigLjfO4POrLaPGM0sNliGYk7A=="], + + "@fontsource/noto-sans-devanagari": ["@fontsource/noto-sans-devanagari@5.3.0", "", {}, "sha512-7khYmipS/5KAUUmrO1DKir8yL8XI00d0+ZE9BqbS8XF1jzObv6CpPOk3UeYxF+Pvi8bx9bBvprBCOcbx/eQWTQ=="], + + "@fontsource/noto-sans-hebrew": ["@fontsource/noto-sans-hebrew@5.3.0", "", {}, "sha512-7owtzuw9D+ipt0g8mcBGOm7MS/mv/REKsjSwEUe749UUsrnG54TSmusNn7oxv2GxbUORAzADLQ8ggdtt7oKkpg=="], + + "@fontsource/unifont": ["@fontsource/unifont@5.3.0", "", {}, "sha512-7cbWRgAV1JVpa6kgwtOcziEG7YiVOF9NGax6ZRhnmB76U5HHbITPD1t5BgeToxFt4De/WoPfG5wH4pWl6BhpNQ=="], + "@fumadocs/tailwind": ["@fumadocs/tailwind@0.0.5", "", { "peerDependencies": { "@tailwindcss/oxide": "^4.0.0", "tailwindcss": "^4.0.0" }, "optionalPeers": ["@tailwindcss/oxide", "tailwindcss"] }, "sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ=="], "@fumari/json-schema-ts": ["@fumari/json-schema-ts@0.0.2", "", { "dependencies": { "esrap": "^2.2.3" }, "peerDependencies": { "json-schema-typed": "^8.0.2" }, "optionalPeers": ["json-schema-typed"] }, "sha512-A2x8nj45r8Kc3Gqa+HpWRF9uzIMc9dySB6L2R2kiyjLHXWBsZUX99Atj5+Yup/iRQXQ9s8AX+uAPwPze7Xn05A=="], diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 28f6391b31d..f3b9218b76f 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -165,6 +165,11 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules/y-protocols ./node_modu COPY --from=deps --chown=nextjs:nodejs /app/node_modules/sharp ./node_modules/sharp COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@img ./node_modules/@img +# Markdown PDF export resolves bundled Unicode fallbacks through these packages at runtime. The +# standalone tracer does not reliably retain fonts referenced through require.resolve, so copy the +# font packages explicitly just like the other runtime assets above. +COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@fontsource ./node_modules/@fontsource + # Copy the isolated-vm worker script COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-vm-worker.cjs ./apps/sim/lib/execution/isolated-vm-worker.cjs From ba3a4f067fbc67a090e37ca55d444cbc10803014 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 20:12:27 -0700 Subject: [PATCH 05/18] fix(files): address PDF renderer edge cases --- .../files/export/[id]/markdown-pdf.test.ts | 11 +++++ .../api/files/export/[id]/markdown-pdf.tsx | 42 +++++++++++++------ apps/sim/app/api/files/export/[id]/route.ts | 13 +----- apps/sim/lib/uploads/client/download.ts | 6 +-- apps/sim/lib/uploads/utils/file-utils.test.ts | 3 +- apps/sim/lib/uploads/utils/file-utils.ts | 8 ++-- 6 files changed, 50 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts index 53c88adfffd..058068ce397 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -120,6 +120,17 @@ ${repeatedParagraphs}` expect(tablePages.every((page) => page.includes('Name') && page.includes('Value'))).toBe(true) }) + it('renders a table that contains only a header', async () => { + const buffer = await renderMarkdownPdf({ + markdown: '| Name | Value |\n| --- | --- |', + title: 'Header-only table', + }) + + const text = (await pdfPagesText(buffer)).join(' ') + expect(text).toContain('Name') + expect(text).toContain('Value') + }) + it('falls back instead of decoding an image above the pixel ceiling', async () => { const oversizedSvg = Buffer.from( '' diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index eb14fa5e6dd..2d8539e9211 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -267,19 +267,31 @@ function fontRuns(value: string): FontRun[] { } }) - for (const [index, character] of characters.entries()) { - if (character.family !== 'Geist' || !character.neutral) continue - - let previous = index - 1 - while (previous >= 0 && characters[previous].neutral) previous -= 1 - let next = index + 1 - while (next < characters.length && characters[next].neutral) next += 1 - const previousFamily = characters[previous]?.family - const nextFamily = characters[next]?.family - if (previousFamily && previousFamily !== 'Geist' && previousFamily === nextFamily) { - character.family = previousFamily - } else if (previousFamily && previousFamily !== 'Geist' && next >= characters.length) { - character.family = previousFamily + let index = 0 + while (index < characters.length) { + if (!characters[index].neutral) { + index += 1 + continue + } + + const start = index + while (index < characters.length && characters[index].neutral) index += 1 + + const previousFamily = characters[start - 1]?.family + const nextFamily = characters[index]?.family + const inheritedFamily = + previousFamily && + previousFamily !== 'Geist' && + (previousFamily === nextFamily || index === characters.length) + ? previousFamily + : undefined + + if (inheritedFamily) { + for (let neutralIndex = start; neutralIndex < index; neutralIndex += 1) { + if (characters[neutralIndex].family === 'Geist') { + characters[neutralIndex].family = inheritedFamily + } + } } } @@ -489,6 +501,10 @@ function estimateTableRowHeight(row: JSONContent, columnCount: number): number { function chunkTableRows(header: JSONContent, rows: JSONContent[]): TableChunk[] { const columnCount = header.content?.length ?? rows[0]?.content?.length ?? 1 const headerHeight = estimateTableRowHeight(header, columnCount) + if (rows.length === 0) { + return [{ rows: [], unbreakable: headerHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }] + } + const chunks: TableChunk[] = [] let current: JSONContent[] = [] let currentHeight = headerHeight diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index a92ad0fa9bc..d4520f2f559 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -25,7 +25,7 @@ import { type ResolvedEmbeddedFileRef, replaceEmbeddedFileRefs, } from '@/lib/uploads/utils/embedded-image-ref' -import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { formatFileSize, isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { encodeFilenameForHeader } from '@/app/api/files/utils' @@ -54,15 +54,6 @@ const PDF_EXPORT_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) -const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) - -function isMarkdown(originalName: string, contentType: string): boolean { - if (MARKDOWN_MIME_TYPES.has(contentType)) return true - const ext = originalName.split('.').pop()?.toLowerCase() ?? '' - return MARKDOWN_EXTENSIONS.has(ext) -} - function safeFilename(name: string): string { return path .basename(name) @@ -141,7 +132,7 @@ export const GET = withRouteHandler( ) } - if (!isMarkdown(record.originalName, record.contentType)) { + if (!isMarkdownFile({ name: record.originalName, type: record.contentType })) { if (format === 'pdf') { return NextResponse.json( { error: 'PDF export is only available for Markdown files.' }, diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index 97655058229..a6ad0fd0f24 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -2,6 +2,7 @@ import { requestRaw } from '@/lib/api/client/request' import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' export function saveBlob(blob: Blob, fileName: string): void { const objectUrl = URL.createObjectURL(blob) @@ -35,10 +36,7 @@ export async function triggerFileDownload( record: WorkspaceFileRecord, options?: { format?: 'pdf' } ): Promise { - const isMarkdown = - record.type === 'text/markdown' || - record.type === 'text/x-markdown' || - /\.(?:md|markdown)$/i.test(record.name) + const isMarkdown = isMarkdownFile(record) if (options?.format === 'pdf' && !isMarkdown) { throw new Error('PDF export is only available for Markdown files') diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 79032282bcf..4521dc6bcf4 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -27,9 +27,10 @@ describe('isMarkdownFile', () => { expect(isMarkdownFile({ name: 'doc.markdown' })).toBe(true) }) - it('is true for a text/markdown MIME even without a .md name', () => { + it('is true for Markdown MIME types even without a .md name', () => { expect(isMarkdownFile({ type: 'text/markdown', name: 'notes' })).toBe(true) expect(isMarkdownFile({ type: 'text/markdown', name: 'doc.txt' })).toBe(true) + expect(isMarkdownFile({ type: 'text/x-markdown', name: 'legacy' })).toBe(true) }) it('is false for non-markdown files', () => { diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index da2c51c0f91..d3c0c9fbb79 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -213,12 +213,12 @@ export function getFileExtension(filename: string): string { /** * Whether a file renders in the collaborative rich markdown editor. Server-safe counterpart to the * client's `isMarkdownFile` (which uses `resolvePreviewType`): the editor treats a file as markdown by - * its `text/markdown` MIME *or* a `.md`/`.markdown` extension — MIME first, matching the client — so a - * `text/markdown` file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc - * merge) to exactly the files that can be open in that editor. + * its Markdown MIME *or* a `.md`/`.markdown` extension — MIME first, matching the client — so a + * Markdown file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc merge) + * to exactly the files that can be open in that editor. */ export function isMarkdownFile(file: { type?: string | null; name: string }): boolean { - if (file.type === 'text/markdown') return true + if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true const ext = getFileExtension(file.name) return ext === 'md' || ext === 'markdown' } From 24808d2acec70c01ecb659fea2062779cc34d2d5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 20:56:46 -0700 Subject: [PATCH 06/18] fix(files): harden Markdown PDF export --- apps/realtime/src/handlers/file-doc.test.ts | 52 +++++++- apps/realtime/src/handlers/file-doc.ts | 124 ++++++++++++++---- .../files/export/[id]/markdown-pdf.test.ts | 23 +++- .../api/files/export/[id]/markdown-pdf.tsx | 92 ++++--------- apps/sim/app/api/files/export/[id]/route.ts | 14 +- .../file-viewer/file-category.test.ts | 8 +- .../components/file-viewer/file-category.ts | 5 +- .../file-viewer/preview-panel.test.ts | 16 +++ .../components/file-viewer/preview-panel.tsx | 14 +- .../collaboration/file-doc-provider.test.ts | 30 ++++- .../collaboration/file-doc-provider.ts | 24 ++++ .../workspace/[workspaceId]/files/files.tsx | 6 +- .../realtime-protocol/src/file-doc.test.ts | 2 + packages/realtime-protocol/src/file-doc.ts | 18 ++- 14 files changed, 314 insertions(+), 114 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 938092d4484..4dc9db77d4c 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -42,7 +42,7 @@ import { } from '@/handlers/file-doc' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' -type Handler = (payload?: unknown) => Promise | void +type Handler = (...args: unknown[]) => Promise | void const ROOM_NAME = 'workspace-file-doc:file-1' @@ -368,6 +368,56 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockFetchFileDocPersist).toHaveBeenCalled() }) + it('acknowledges an export flush only after the latest live edit is persisted', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + mockFetchFileDocPersist.mockClear() + const acknowledge = vi.fn() + + await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) + + expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) + expect(acknowledge).toHaveBeenCalledWith({ ok: true }) + }) + + it('rejects an export flush when the live document cannot be persisted', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + mockFetchFileDocPersist.mockResolvedValueOnce({ status: 'conflict' }) + const acknowledge = vi.fn() + + await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) + + expect(acknowledge).toHaveBeenCalledWith({ + ok: false, + error: 'Unable to save the latest document changes for export', + }) + }) + it('drops document frames and evicts once the editor loses write access mid-session', async () => { // The join-time check is not a standing right: a collaborator downgraded to `read` // (or removed) must stop landing durable edits on the socket they already hold. diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index a0152fd85f6..63c1b2eb00d 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -31,6 +31,8 @@ import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS, type FileDocPresenceUser, + type FlushFileDocPayload, + type FlushFileDocResult, type JoinFileDocPayload, type LeaveFileDocPayload, toFileDocBytes, @@ -81,6 +83,16 @@ const PERSIST_MAX_WAIT_MS = 20_000 const FINAL_VERSION_RETRIES = 2 const FINAL_VERSION_RETRY_MS = 100 +type PersistMode = 'debounced' | 'final' | 'requested' +type PersistOutcome = + | 'unchanged' + | 'persisted' + | 'missing' + | 'deferred' + | 'conflict' + | 'deduplicated' + | 'failed' + /** Cross-task merge lock. The TTL must exceed the whole critical section it guards — stream fold + * `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish — so the lock never expires * mid-merge and lets a second task race the same base; hence `mergeRequestMs` plus generous headroom. @@ -260,27 +272,31 @@ function schedulePersist(name: string, room: FileDocRoom): void { room.persistTimer = setTimeout(() => { room.persistTimer = null room.persistDeadline = null - void flushPersist(name, room, false) + void flushPersist(name, room, 'debounced') }, delay) } /** - * Project the live doc to markdown and write it durably via the app. `final` (last collaborator - * leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW + * Project the live doc to markdown and write it durably via the app. A final or explicitly requested + * flush always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks - * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure - * is retried on the next debounce; the stream holds the state meanwhile). + * editing the same file don't each write a redundant blob version. Returns an outcome so an export can + * wait for durable success; background callers still treat failures as best-effort. * * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or * a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream * holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't * clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a - * fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the + * fallback before any await, so a `void flushPersist(name, room, 'final')` fired immediately before the * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative. */ -async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise { +async function flushPersist( + name: string, + room: FileDocRoom, + mode: PersistMode +): Promise { // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). - if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return + if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return 'unchanged' const store = getFileDocStore() const workspaceId = room.workspaceId const userId = room.lastEditorUserId @@ -290,7 +306,10 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // Capture the AUTHORITATIVE doc state: the shared stream when enabled (a copilot merge or a peer's // edit published by another task may not be integrated into THIS task's `room.doc` yet), else the - // local snapshot. Re-read each attempt so a post-reconcile retry projects the converged state. + // local snapshot. A requested export flush merges both CRDT snapshots: the socket's immediately + // preceding edit can still be in the stream publisher's fire-and-forget queue, while a peer edit can + // already be in the stream but not this task's doc. The CRDT union covers both without another save + // path or waiting on the normal debounce. const captureState = async (): Promise => { if (!store.enabled) { // Single-pod: re-read the live doc so a post-reconcile retry projects the CONVERGED state, not the @@ -302,12 +321,23 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr : localState } try { - return (await store.getStreamState(name)) ?? localState + const sharedState = await store.getStreamState(name) + if (mode !== 'requested' || !sharedState || !localState) return sharedState ?? localState + + const merged = new Y.Doc() + try { + Y.applyUpdate(merged, sharedState) + Y.applyUpdate(merged, localState) + return Y.encodeStateAsUpdate(merged) + } finally { + merged.destroy() + } } catch (streamError) { // A transient Redis read must NOT drop the write when we already hold a valid local snapshot — - // else the last-disconnect flush loses the session's edits as the room is torn down. But once a - // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a - // failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot. + // else the last-disconnect flush loses the session's edits as the room is torn down. An explicit + // export flush can safely fail and retry, so do not risk omitting a peer edit when the shared state + // is temporarily unavailable. + if (mode === 'requested') throw streamError if (!localState) throw streamError logger.warn(`Stream state unavailable for file ${room.fileId}; persisting local snapshot`, { error: getErrorMessage(streamError), @@ -327,8 +357,11 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr } try { - if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))) - return + if ( + mode === 'debounced' && + !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs)) + ) + return 'deduplicated' // The If-Match token: the durable content version the live doc is synced to. let ifMatch = await currentVersion() @@ -338,7 +371,7 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // unset version never appears, and the flush must not stall teardown. for ( let i = 0; - ifMatch === undefined && final && store.enabled && i < FINAL_VERSION_RETRIES; + ifMatch === undefined && mode !== 'debounced' && store.enabled && i < FINAL_VERSION_RETRIES; i++ ) { await sleep(FINAL_VERSION_RETRY_MS) @@ -349,19 +382,24 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // still at the version the live doc synced from, so a projection can never silently clobber an // out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below). const docState = await captureState() - if (!docState) return // nothing seeded/authoritative to persist yet + if (!docState) return 'unchanged' // nothing seeded/authoritative to persist yet + // Make an acknowledged multi-replica flush a real snapshot handshake: the normal keystroke publish + // is fire-and-forget, so append the converged snapshot and await Redis before updating the durable + // blob. If Redis is unavailable, fail the export instead of acknowledging state that a later relay + // persist could overwrite from an incomplete stream. + if (mode === 'requested' && store.enabled) await store.publishAndWait(name, docState) const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) - if (result.status === 'missing') return // the file was deleted; nothing to write + if (result.status === 'missing') return 'missing' // the file was deleted; nothing to write if (result.status === 'deferred') { // No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in // the stream; a later persist writes them once the version is re-established. logger.warn(`Persist deferred for file ${room.fileId} (no synced version available yet)`) - return + return 'deferred' } if (result.status === 'persisted') { room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version) void store.setSyncedVersion(name, result.version) - return + return 'persisted' } // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge @@ -375,8 +413,10 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr logger.warn( `Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative` ) + return 'conflict' } catch (error) { logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) }) + return 'failed' } } @@ -446,7 +486,7 @@ function destroyRoomIfIdle(name: string) { } // Final durable flush BEFORE teardown — `flushPersist` encodes the doc synchronously (before the // destroy below) and awaits the write in the background. Best-effort; never throws. - void flushPersist(name, room, true) + void flushPersist(name, room, 'final') getFileDocStore().detachRoom(name) room.awareness.destroy() room.doc.destroy() @@ -461,9 +501,9 @@ function destroyRoomIfIdle(name: string) { * process is exiting); only their durable state is secured. */ export async function flushAllFileDocRooms(): Promise { - const flushes: Promise[] = [] + const flushes: Promise[] = [] for (const [name, room] of fileDocRooms) { - if (room.edited) flushes.push(flushPersist(name, room, true)) + if (room.edited) flushes.push(flushPersist(name, room, 'final')) } await Promise.all(flushes) } @@ -1229,6 +1269,44 @@ export function setupWorkspaceFileDocHandlers( } }) + socket.on( + FILE_DOC_EVENTS.FLUSH, + async (payload: FlushFileDocPayload, acknowledge?: (result: FlushFileDocResult) => void) => { + if (typeof acknowledge !== 'function') return + if (!payload || typeof payload.fileId !== 'string' || payload.fileId.length === 0) { + acknowledge({ ok: false, error: 'Invalid file document flush request' }) + return + } + + const name = socketToRoomName.get(socket.id) + const requestedName = roomName(fileDocRoom(payload.fileId)) + // A file with no live editor on this socket has no pending client edits to flush; its durable + // blob is already the export source. This also keeps cold-load and read-only exports immediate. + if (name !== requestedName) { + acknowledge({ ok: true }) + return + } + + const room = fileDocRooms.get(name) + if (!room || !isFileDocWriteAllowed(socket, io, name)) { + acknowledge({ ok: false, error: 'Unable to prepare the current document for export' }) + return + } + + // Socket.IO preserves event order on one connection, so all Yjs update frames emitted before + // this request have already been applied. Replace the pending debounce with this awaited write. + if (room.persistTimer) clearTimeout(room.persistTimer) + room.persistTimer = null + room.persistDeadline = null + const outcome = await flushPersist(name, room, 'requested') + if (outcome === 'persisted' || outcome === 'unchanged' || outcome === 'missing') { + acknowledge({ ok: true }) + return + } + acknowledge({ ok: false, error: 'Unable to save the latest document changes for export' }) + } + ) + socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, io, data)) socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => { diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts index 058068ce397..3b6157af616 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -46,6 +46,8 @@ describe('Markdown PDF rendering', () => { Smart quotes “work” and Greek Ω stays readable. +Common emoji stay readable too: 🚀 😀 + 中文排版应该清晰易读。 العربية يجب أن تكون متصلة ومقروءة. हिन्दी पाठ स्पष्ट और पठनीय होना चाहिए। עברית צריכה להיות ברורה וקריאה. - First item @@ -85,6 +87,9 @@ ${repeatedParagraphs}` // PDF extractors expose visually positioned Indic vowel marks before their base character. expect(text).toMatch(/[\u0900-\u097f]{4,}/u) expect(text).toContain('עברית') + expect(text).toContain('[emoji U+1F680]') + expect(text).toContain('[emoji U+1F600]') + expect(text).not.toContain('�') expect(text).not.toContain('Image: Embedded image') const parsed = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }).promise @@ -103,7 +108,7 @@ ${repeatedParagraphs}` } }) - it('keeps long table rows together and repeats the header across table pages', async () => { + it('lets a long table paginate without moving the whole table to a later page', async () => { const rows = Array.from( { length: 90 }, (_, index) => @@ -117,7 +122,21 @@ ${repeatedParagraphs}` const pages = await pdfPagesText(buffer) const tablePages = pages.filter((page) => page.includes('Row ')) expect(tablePages.length).toBeGreaterThan(1) - expect(tablePages.every((page) => page.includes('Name') && page.includes('Value'))).toBe(true) + expect(pages[0]).toContain('Row 1') + expect(pages.join(' ')).toContain('Row 90') + }) + + it('allows a table row taller than a page to wrap without losing its content', async () => { + const cell = `ROW-START ${'wrapping table content '.repeat(900)} ROW-END` + const buffer = await renderMarkdownPdf({ + markdown: `| Name | Value |\n| --- | --- |\n| Tall row | ${cell} |`, + title: 'Tall table row', + }) + + const pages = await pdfPagesText(buffer) + expect(pages.length).toBeGreaterThan(1) + expect(pages.join(' ')).toContain('ROW-START') + expect(pages.join(' ')).toContain('ROW-END') }) it('renders a table that contains only a header', async () => { diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 2d8539e9211..a2535ad1c9c 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -97,7 +97,6 @@ const MAX_PDF_IMAGE_BYTES = 12 * 1024 * 1024 const MAX_PDF_TOTAL_IMAGE_BYTES = 32 * 1024 * 1024 const MAX_PDF_DOCUMENT_NODES = 20_000 const MAX_PDF_TOP_LEVEL_BLOCKS = 3_000 -const MAX_UNBREAKABLE_TABLE_HEIGHT = 620 const PDF_TABLE_CONTENT_WIDTH = 499 Font.register({ @@ -159,11 +158,6 @@ interface FontRun { text: string } -interface TableChunk { - rows: JSONContent[] - unbreakable: boolean -} - const styles = StyleSheet.create({ page: { backgroundColor: '#ffffff', @@ -256,14 +250,20 @@ function fontRuns(value: string): FontRun[] { ? undefined : (staticFallbackFonts.find(({ glyphs }) => glyphs.hasGlyphForCodePoint(codePoint)) ?? (unifont.glyphs.hasGlyphForCodePoint(codePoint) ? unifont : undefined)) - const family = - codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) - ? 'Geist' - : (fallback?.family ?? 'Unifont') + const hasGeistGlyph = codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) + const unsupported = !hasGeistGlyph && !fallback + const family = !hasGeistGlyph && fallback ? fallback.family : 'Geist' return { family, neutral: /^[\p{N}\p{P}\p{Z}\s]$/u.test(character), - text: family === 'Geist' || fallback ? character : '�', + // React PDF/fontkit does not shape astral emoji correctly from bundled monochrome fonts and + // cannot embed the platform's color font. Preserve unsupported emoji as an explicit code-point + // label instead of silently corrupting it into an unrelated glyph or replacement character. + text: !unsupported + ? character + : codePoint !== undefined && codePoint > 0xffff + ? `[emoji U+${codePoint.toString(16).toUpperCase()}]` + : '�', } }) @@ -486,50 +486,9 @@ function renderList( ) } -function estimateTableRowHeight(row: JSONContent, columnCount: number): number { - const columnWidth = PDF_TABLE_CONTENT_WIDTH / Math.max(columnCount, 1) - const charactersPerLine = Math.max(8, Math.floor(columnWidth / 4.8)) - const lines = Math.max( - 1, - ...(row.content ?? []).map((cell) => - Math.ceil(Math.max(nodeText(cell).length, 1) / charactersPerLine) - ) - ) - return 10 + lines * 12.5 -} - -function chunkTableRows(header: JSONContent, rows: JSONContent[]): TableChunk[] { - const columnCount = header.content?.length ?? rows[0]?.content?.length ?? 1 - const headerHeight = estimateTableRowHeight(header, columnCount) - if (rows.length === 0) { - return [{ rows: [], unbreakable: headerHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }] - } - - const chunks: TableChunk[] = [] - let current: JSONContent[] = [] - let currentHeight = headerHeight - - const flush = () => { - if (current.length === 0) return - chunks.push({ rows: current, unbreakable: currentHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }) - current = [] - currentHeight = headerHeight - } - - for (const row of rows) { - const rowHeight = estimateTableRowHeight(row, columnCount) - if (current.length > 0 && currentHeight + rowHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() - current.push(row) - currentHeight += rowHeight - if (rowHeight + headerHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() - } - flush() - return chunks -} - function renderTableRow(row: JSONContent, key: string, header: boolean): ReactNode { return ( - + {(row.content ?? []).map((cell, index) => ( cell.type === 'tableHeader') ?? false - const header = hasHeader ? firstRow : { type: 'tableRow', content: [] } - const bodyRows = hasHeader ? rows.slice(1) : rows - const chunks = hasHeader - ? chunkTableRows(header, bodyRows) - : [{ rows: bodyRows, unbreakable: false }] - - return chunks.map((chunk, index) => ( - - {hasHeader ? renderTableRow(header, `${key}-${index}-header`, true) : null} - {chunk.rows.map((row, rowIndex) => - renderTableRow(row, `${key}-${index}-row-${rowIndex}`, false) + return ( + + {rows.map((row, index) => + renderTableRow(row, `${key}-row-${index}`, hasHeader && index === 0) )} - )) + ) } function renderBlock( @@ -647,7 +599,7 @@ async function normalizeImages( images: ReadonlyMap ): Promise> { const normalized = new Map() - let totalInputPixels = 0 + let totalDecodedInputPixels = 0 let totalOutputPixels = 0 let totalImageBytes = 0 @@ -663,11 +615,10 @@ async function normalizeImages( const inputPixels = metadata.width * metadata.height if ( !Number.isSafeInteger(inputPixels) || - totalInputPixels + inputPixels > MAX_PDF_TOTAL_INPUT_PIXELS + totalDecodedInputPixels + inputPixels > MAX_PDF_TOTAL_INPUT_PIXELS ) { continue } - totalInputPixels += inputPixels const scale = Math.min( 1, @@ -689,6 +640,11 @@ async function normalizeImages( }) .png() .toBuffer() + // Count the expensive decode once it has happened even when the encoded PNG is later rejected; + // output pixels/bytes below count only images retained for React PDF layout. This distinction + // prevents repeated rejected images from escaping the CPU/memory budget without penalizing images + // skipped before decoding. + totalDecodedInputPixels += inputPixels if ( data.length > MAX_PDF_IMAGE_BYTES || totalImageBytes + data.length > MAX_PDF_TOTAL_IMAGE_BYTES diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index d4520f2f559..5f75aef2d74 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -300,6 +300,14 @@ export const GET = withRouteHandler( } ) + if (format === 'pdf') { + return respondWithPdf( + new Map( + fetched.flatMap((result) => (result ? [[result.imageKey, result.buffer] as const] : [])) + ) + ) + } + const assetMap = new Map() const usedFilenames = new Set() @@ -312,12 +320,6 @@ export const GET = withRouteHandler( assetMap.set(imageKey, { filename, buffer }) } - if (format === 'pdf') { - return respondWithPdf( - new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, asset.buffer])) - ) - } - mdContent = replaceEmbeddedFileRefs( mdContent, new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, `./assets/${asset.filename}`])) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts index 00480f609dc..af19cb6d3f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts @@ -12,6 +12,11 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({ const lastDot = filename.lastIndexOf('.') return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' }, + isMarkdownFile: (file: { type?: string | null; name: string }): boolean => { + if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true + const extension = file.name.split('.').at(-1)?.toLowerCase() + return extension === 'md' || extension === 'markdown' + }, })) import { resolveFileCategory } from './file-category' @@ -21,6 +26,7 @@ describe('resolveFileCategory — MIME type routing', () => { it.each([ 'text/plain', 'text/markdown', + 'text/x-markdown', 'application/json', 'application/x-yaml', 'text/csv', @@ -130,7 +136,7 @@ describe('resolveFileCategory — MIME type routing', () => { describe('resolveFileCategory — extension fallback', () => { describe('text-editable extensions', () => { - it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( + it.each(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( '.%s → text-editable', (ext) => { expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts index 485ebdeda56..6683bdeabd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts @@ -1,8 +1,7 @@ -import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { getFileExtension, isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' const TEXT_EDITABLE_MIME_TYPES = new Set([ - 'text/markdown', 'text/plain', 'application/json', 'application/x-yaml', @@ -23,7 +22,6 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([ ]) const TEXT_EDITABLE_EXTENSIONS = new Set([ - 'md', 'txt', 'json', 'yaml', @@ -128,6 +126,7 @@ export type FileCategory = | 'unsupported' export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { + if (isMarkdownFile({ type: mimeType, name: filename })) return 'text-editable' if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts new file mode 100644 index 00000000000..35a79f13eef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resolvePreviewType } from './preview-panel' + +describe('resolvePreviewType', () => { + it.each([ + ['text/markdown', 'notes.txt'], + ['text/x-markdown', 'notes.txt'], + [null, 'notes.md'], + [null, 'notes.markdown'], + ])('uses the shared Markdown eligibility for %s / %s', (mimeType, filename) => { + expect(resolvePreviewType(mimeType, filename)).toBe('markdown') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 2b5b9c997d0..c932b70d63b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -3,7 +3,10 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import '@sim/emcn/components/code/code.css' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' -import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { + getFileExtension, + isMarkdownFile as isSharedMarkdownFile, +} from '@/lib/uploads/utils/file-utils' import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import' import { DataTable } from './data-table' import { MermaidDiagram } from './mermaid-diagram' @@ -12,7 +15,6 @@ import { ZoomablePreview } from './zoomable-preview' type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null const PREVIEWABLE_MIME_TYPES: Record = { - 'text/markdown': 'markdown', 'text/html': 'html', 'text/csv': 'csv', 'image/svg+xml': 'svg', @@ -20,7 +22,6 @@ const PREVIEWABLE_MIME_TYPES: Record = { } const PREVIEWABLE_EXTENSIONS: Record = { - md: 'markdown', html: 'html', htm: 'html', csv: 'csv', @@ -29,9 +30,14 @@ const PREVIEWABLE_EXTENSIONS: Record = { } /** All extensions that have a rich preview renderer. */ -export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS)) +export const RICH_PREVIEWABLE_EXTENSIONS = new Set([ + ...Object.keys(PREVIEWABLE_EXTENSIONS), + 'md', + 'markdown', +]) export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType { + if (isSharedMarkdownFile({ type: mimeType, name: filename })) return 'markdown' if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType] const ext = getFileExtension(filename) return PREVIEWABLE_EXTENSIONS[ext] ?? null diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 689c2ee6c77..351c3052006 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -13,7 +13,7 @@ import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown' -import { FileDocProvider } from './file-doc-provider' +import { FileDocProvider, flushFileDocForExport } from './file-doc-provider' /** A minimal fake Socket.IO client whose server→client events can be fired in tests. */ function createSocket(connected = true) { @@ -57,6 +57,34 @@ function emittedMessages(emit: ReturnType) { .map(([, payload]) => payload as Uint8Array) } +describe('flushFileDocForExport', () => { + it('resolves only after the relay acknowledges a durable flush', async () => { + const emit = vi.fn( + (event: string, payload: { fileId: string }, acknowledge: (result: { ok: true }) => void) => { + expect(event).toBe(FILE_DOC_EVENTS.FLUSH) + expect(payload).toEqual({ fileId: 'file-1' }) + acknowledge({ ok: true }) + } + ) + const socket = { connected: true, emit } as unknown as Socket + + await expect(flushFileDocForExport(socket, 'file-1')).resolves.toBeUndefined() + }) + + it('surfaces a failed flush and a disconnected socket', async () => { + const emit = vi.fn( + (_event: string, _payload: unknown, acknowledge: (result: unknown) => void) => + acknowledge({ ok: false, error: 'Persist failed' }) + ) + await expect( + flushFileDocForExport({ connected: true, emit } as unknown as Socket, 'file-1') + ).rejects.toThrow('Persist failed') + await expect(flushFileDocForExport(null, 'file-1')).rejects.toThrow( + 'Connect to realtime before exporting your latest changes.' + ) + }) +}) + describe('FileDocProvider', () => { it('joins immediately with its client id when the socket is already connected', () => { const { doc, emit } = createProvider(true) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 84bab0733bc..c4b338ce897 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -7,6 +7,7 @@ import { FILE_DOC_MESSAGE_TYPE, FILE_DOC_SEED, FILE_DOC_TIMEOUTS, + type FlushFileDocResult, type JoinFileDocError, type JoinFileDocSuccess, toFileDocBytes, @@ -46,6 +47,29 @@ interface FileDocProviderEvents { */ const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs +/** + * Wait for the realtime relay to persist every file-doc update emitted before this call. Exports read + * the durable file blob, so editable clients use this acknowledgement immediately before requesting an + * export rather than racing the relay's normal edit debounce. + */ +export function flushFileDocForExport(socket: Socket | null, fileId: string): Promise { + if (!socket?.connected) { + return Promise.reject(new Error('Connect to realtime before exporting your latest changes.')) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timed out while saving the latest document changes.')) + }, FILE_DOC_TIMEOUTS.flushAckMs) + + socket.emit(FILE_DOC_EVENTS.FLUSH, { fileId }, (result: FlushFileDocResult) => { + clearTimeout(timer) + if (result.ok) resolve() + else reject(new Error(result.error)) + }) + }) +} + /** * Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the * embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 681061e9435..9528472fc02 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -86,6 +86,7 @@ import { isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FileDocAvatars } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars' +import { flushFileDocForExport } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider' import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' @@ -106,6 +107,7 @@ import { import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useSocket } from '@/app/workspace/providers/socket-provider' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { @@ -239,6 +241,7 @@ export function Files() { typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null const userPermissions = useUserPermissionsContext() const canEdit = userPermissions.canEdit === true + const { socket } = useSocket() const { config: permissionConfig } = usePermissionConfig() // Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the @@ -1072,6 +1075,7 @@ export function Files() { setPdfDownloadPending(true) } try { + if (isPdf && canEdit) await flushFileDocForExport(socket, file.id) await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { workspace_id: workspaceId, @@ -1088,7 +1092,7 @@ export function Files() { } } }, - [workspaceId] + [canEdit, socket, workspaceId] ) const deleteTargetRef = useRef(deleteTarget) diff --git a/packages/realtime-protocol/src/file-doc.test.ts b/packages/realtime-protocol/src/file-doc.test.ts index 48fd7db5b8a..93eb16745bd 100644 --- a/packages/realtime-protocol/src/file-doc.test.ts +++ b/packages/realtime-protocol/src/file-doc.test.ts @@ -9,5 +9,7 @@ describe('FILE_DOC_TIMEOUTS ordering invariants', () => { // The relay's `/seed` fetch must finish before the client's readiness deadline lapses into its // read-only fallback, or a late-but-successful seed can never reach the client. expect(FILE_DOC_TIMEOUTS.seedRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.readinessDeadlineMs) + // An export flush waits for the relay's durable `/persist` request to finish before acking. + expect(FILE_DOC_TIMEOUTS.persistRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.flushAckMs) }) }) diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts index 3e50cf097df..1fccbddb8be 100644 --- a/packages/realtime-protocol/src/file-doc.ts +++ b/packages/realtime-protocol/src/file-doc.ts @@ -23,6 +23,8 @@ export const FILE_DOC_EVENTS = { JOIN_ERROR: 'join-file-doc-error', /** Client → server: leave the session ({@link LeaveFileDocPayload}). */ LEAVE: 'leave-file-doc', + /** Client → server: durably persist the current live document before export. */ + FLUSH: 'flush-file-doc', /** Both directions: a framed Yjs message (binary), tagged by {@link FILE_DOC_MESSAGE_TYPE}. */ MESSAGE: 'file-doc-message', /** @@ -102,10 +104,9 @@ export const FILE_DOC_SEED = { * The seed request gets more headroom than the merge because it reads a (possibly cold) blob before * converting; the merge is a pure in-memory conversion the caller fully supplies. * - * `persistRequestMs` (relay → app `/persist`) stands alone — no client waits on it (the relay flushes - * the live doc to durable markdown debounced during editing and on the last collaborator leaving), so - * it forms no ordering invariant. It gets seed-level headroom because, like the seed, it crosses a - * durable blob write (Yjs → markdown → storage), not just an in-memory conversion. + * `flushAckMs` (client → relay `flush`) wraps `persistRequestMs` (relay → app `/persist`), so + * `persistRequestMs < flushAckMs`. The acknowledged flush is used before exporting a live document; + * ordinary editing still persists on the relay's debounce and last-collaborator leave. */ export const FILE_DOC_TIMEOUTS = { seedRequestMs: 8_000, @@ -113,6 +114,7 @@ export const FILE_DOC_TIMEOUTS = { applyEditMs: 6_000, readinessDeadlineMs: 12_000, persistRequestMs: 8_000, + flushAckMs: 10_000, } as const /** Client → server join request. `fileId` is the `workspace_files.id`. */ @@ -144,6 +146,14 @@ export interface LeaveFileDocPayload { fileId: string } +/** Client → server request to durably persist a joined live document before an export reads it. */ +export interface FlushFileDocPayload { + fileId: string +} + +/** Server acknowledgement for a {@link FlushFileDocPayload}. */ +export type FlushFileDocResult = { ok: true } | { ok: false; error: string } + /** One collaborator session in a {@link FileDocPresence} roster — server-authenticated identity. * Keyed per socket (session), not per user: the client excludes its OWN `socketId` and then * dedupes the rest per user for the avatar stack, so a second tab of the same account still From 6c9b3e405f6dcf9125193dc853b9c99a4c00bdc5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 23:07:24 -0700 Subject: [PATCH 07/18] refactor(files): align PDF export with Markdown download --- apps/realtime/src/handlers/file-doc.test.ts | 52 +------- apps/realtime/src/handlers/file-doc.ts | 124 ++++-------------- .../collaboration/file-doc-provider.test.ts | 30 +---- .../collaboration/file-doc-provider.ts | 24 ---- .../workspace/[workspaceId]/files/files.tsx | 6 +- .../realtime-protocol/src/file-doc.test.ts | 2 - packages/realtime-protocol/src/file-doc.ts | 18 +-- 7 files changed, 30 insertions(+), 226 deletions(-) diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 4dc9db77d4c..938092d4484 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -42,7 +42,7 @@ import { } from '@/handlers/file-doc' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' -type Handler = (...args: unknown[]) => Promise | void +type Handler = (payload?: unknown) => Promise | void const ROOM_NAME = 'workspace-file-doc:file-1' @@ -368,56 +368,6 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockFetchFileDocPersist).toHaveBeenCalled() }) - it('acknowledges an export flush only after the latest live edit is persisted', async () => { - mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) - const { io } = createIo() - const { handlers } = setup('socket-1', io) - await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) - await flushMicrotasks() - - const edit = new Y.Doc() - edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') - handlers[FILE_DOC_EVENTS.MESSAGE]( - frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => - syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) - ) - ) - await flushMicrotasks() - mockFetchFileDocPersist.mockClear() - const acknowledge = vi.fn() - - await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) - - expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) - expect(acknowledge).toHaveBeenCalledWith({ ok: true }) - }) - - it('rejects an export flush when the live document cannot be persisted', async () => { - mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) - const { io } = createIo() - const { handlers } = setup('socket-1', io) - await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) - await flushMicrotasks() - - const edit = new Y.Doc() - edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') - handlers[FILE_DOC_EVENTS.MESSAGE]( - frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => - syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) - ) - ) - await flushMicrotasks() - mockFetchFileDocPersist.mockResolvedValueOnce({ status: 'conflict' }) - const acknowledge = vi.fn() - - await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) - - expect(acknowledge).toHaveBeenCalledWith({ - ok: false, - error: 'Unable to save the latest document changes for export', - }) - }) - it('drops document frames and evicts once the editor loses write access mid-session', async () => { // The join-time check is not a standing right: a collaborator downgraded to `read` // (or removed) must stop landing durable edits on the socket they already hold. diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index 63c1b2eb00d..a0152fd85f6 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -31,8 +31,6 @@ import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS, type FileDocPresenceUser, - type FlushFileDocPayload, - type FlushFileDocResult, type JoinFileDocPayload, type LeaveFileDocPayload, toFileDocBytes, @@ -83,16 +81,6 @@ const PERSIST_MAX_WAIT_MS = 20_000 const FINAL_VERSION_RETRIES = 2 const FINAL_VERSION_RETRY_MS = 100 -type PersistMode = 'debounced' | 'final' | 'requested' -type PersistOutcome = - | 'unchanged' - | 'persisted' - | 'missing' - | 'deferred' - | 'conflict' - | 'deduplicated' - | 'failed' - /** Cross-task merge lock. The TTL must exceed the whole critical section it guards — stream fold + * `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish — so the lock never expires * mid-merge and lets a second task race the same base; hence `mergeRequestMs` plus generous headroom. @@ -272,31 +260,27 @@ function schedulePersist(name: string, room: FileDocRoom): void { room.persistTimer = setTimeout(() => { room.persistTimer = null room.persistDeadline = null - void flushPersist(name, room, 'debounced') + void flushPersist(name, room, false) }, delay) } /** - * Project the live doc to markdown and write it durably via the app. A final or explicitly requested - * flush always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW + * Project the live doc to markdown and write it durably via the app. `final` (last collaborator + * leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks - * editing the same file don't each write a redundant blob version. Returns an outcome so an export can - * wait for durable success; background callers still treat failures as best-effort. + * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure + * is retried on the next debounce; the stream holds the state meanwhile). * * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or * a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream * holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't * clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a - * fallback before any await, so a `void flushPersist(name, room, 'final')` fired immediately before the + * fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative. */ -async function flushPersist( - name: string, - room: FileDocRoom, - mode: PersistMode -): Promise { +async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise { // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). - if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return 'unchanged' + if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return const store = getFileDocStore() const workspaceId = room.workspaceId const userId = room.lastEditorUserId @@ -306,10 +290,7 @@ async function flushPersist( // Capture the AUTHORITATIVE doc state: the shared stream when enabled (a copilot merge or a peer's // edit published by another task may not be integrated into THIS task's `room.doc` yet), else the - // local snapshot. A requested export flush merges both CRDT snapshots: the socket's immediately - // preceding edit can still be in the stream publisher's fire-and-forget queue, while a peer edit can - // already be in the stream but not this task's doc. The CRDT union covers both without another save - // path or waiting on the normal debounce. + // local snapshot. Re-read each attempt so a post-reconcile retry projects the converged state. const captureState = async (): Promise => { if (!store.enabled) { // Single-pod: re-read the live doc so a post-reconcile retry projects the CONVERGED state, not the @@ -321,23 +302,12 @@ async function flushPersist( : localState } try { - const sharedState = await store.getStreamState(name) - if (mode !== 'requested' || !sharedState || !localState) return sharedState ?? localState - - const merged = new Y.Doc() - try { - Y.applyUpdate(merged, sharedState) - Y.applyUpdate(merged, localState) - return Y.encodeStateAsUpdate(merged) - } finally { - merged.destroy() - } + return (await store.getStreamState(name)) ?? localState } catch (streamError) { // A transient Redis read must NOT drop the write when we already hold a valid local snapshot — - // else the last-disconnect flush loses the session's edits as the room is torn down. An explicit - // export flush can safely fail and retry, so do not risk omitting a peer edit when the shared state - // is temporarily unavailable. - if (mode === 'requested') throw streamError + // else the last-disconnect flush loses the session's edits as the room is torn down. But once a + // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a + // failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot. if (!localState) throw streamError logger.warn(`Stream state unavailable for file ${room.fileId}; persisting local snapshot`, { error: getErrorMessage(streamError), @@ -357,11 +327,8 @@ async function flushPersist( } try { - if ( - mode === 'debounced' && - !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs)) - ) - return 'deduplicated' + if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))) + return // The If-Match token: the durable content version the live doc is synced to. let ifMatch = await currentVersion() @@ -371,7 +338,7 @@ async function flushPersist( // unset version never appears, and the flush must not stall teardown. for ( let i = 0; - ifMatch === undefined && mode !== 'debounced' && store.enabled && i < FINAL_VERSION_RETRIES; + ifMatch === undefined && final && store.enabled && i < FINAL_VERSION_RETRIES; i++ ) { await sleep(FINAL_VERSION_RETRY_MS) @@ -382,24 +349,19 @@ async function flushPersist( // still at the version the live doc synced from, so a projection can never silently clobber an // out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below). const docState = await captureState() - if (!docState) return 'unchanged' // nothing seeded/authoritative to persist yet - // Make an acknowledged multi-replica flush a real snapshot handshake: the normal keystroke publish - // is fire-and-forget, so append the converged snapshot and await Redis before updating the durable - // blob. If Redis is unavailable, fail the export instead of acknowledging state that a later relay - // persist could overwrite from an incomplete stream. - if (mode === 'requested' && store.enabled) await store.publishAndWait(name, docState) + if (!docState) return // nothing seeded/authoritative to persist yet const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) - if (result.status === 'missing') return 'missing' // the file was deleted; nothing to write + if (result.status === 'missing') return // the file was deleted; nothing to write if (result.status === 'deferred') { // No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in // the stream; a later persist writes them once the version is re-established. logger.warn(`Persist deferred for file ${room.fileId} (no synced version available yet)`) - return 'deferred' + return } if (result.status === 'persisted') { room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version) void store.setSyncedVersion(name, result.version) - return 'persisted' + return } // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge @@ -413,10 +375,8 @@ async function flushPersist( logger.warn( `Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative` ) - return 'conflict' } catch (error) { logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) }) - return 'failed' } } @@ -486,7 +446,7 @@ function destroyRoomIfIdle(name: string) { } // Final durable flush BEFORE teardown — `flushPersist` encodes the doc synchronously (before the // destroy below) and awaits the write in the background. Best-effort; never throws. - void flushPersist(name, room, 'final') + void flushPersist(name, room, true) getFileDocStore().detachRoom(name) room.awareness.destroy() room.doc.destroy() @@ -501,9 +461,9 @@ function destroyRoomIfIdle(name: string) { * process is exiting); only their durable state is secured. */ export async function flushAllFileDocRooms(): Promise { - const flushes: Promise[] = [] + const flushes: Promise[] = [] for (const [name, room] of fileDocRooms) { - if (room.edited) flushes.push(flushPersist(name, room, 'final')) + if (room.edited) flushes.push(flushPersist(name, room, true)) } await Promise.all(flushes) } @@ -1269,44 +1229,6 @@ export function setupWorkspaceFileDocHandlers( } }) - socket.on( - FILE_DOC_EVENTS.FLUSH, - async (payload: FlushFileDocPayload, acknowledge?: (result: FlushFileDocResult) => void) => { - if (typeof acknowledge !== 'function') return - if (!payload || typeof payload.fileId !== 'string' || payload.fileId.length === 0) { - acknowledge({ ok: false, error: 'Invalid file document flush request' }) - return - } - - const name = socketToRoomName.get(socket.id) - const requestedName = roomName(fileDocRoom(payload.fileId)) - // A file with no live editor on this socket has no pending client edits to flush; its durable - // blob is already the export source. This also keeps cold-load and read-only exports immediate. - if (name !== requestedName) { - acknowledge({ ok: true }) - return - } - - const room = fileDocRooms.get(name) - if (!room || !isFileDocWriteAllowed(socket, io, name)) { - acknowledge({ ok: false, error: 'Unable to prepare the current document for export' }) - return - } - - // Socket.IO preserves event order on one connection, so all Yjs update frames emitted before - // this request have already been applied. Replace the pending debounce with this awaited write. - if (room.persistTimer) clearTimeout(room.persistTimer) - room.persistTimer = null - room.persistDeadline = null - const outcome = await flushPersist(name, room, 'requested') - if (outcome === 'persisted' || outcome === 'unchanged' || outcome === 'missing') { - acknowledge({ ok: true }) - return - } - acknowledge({ ok: false, error: 'Unable to save the latest document changes for export' }) - } - ) - socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, io, data)) socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 351c3052006..689c2ee6c77 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -13,7 +13,7 @@ import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown' -import { FileDocProvider, flushFileDocForExport } from './file-doc-provider' +import { FileDocProvider } from './file-doc-provider' /** A minimal fake Socket.IO client whose server→client events can be fired in tests. */ function createSocket(connected = true) { @@ -57,34 +57,6 @@ function emittedMessages(emit: ReturnType) { .map(([, payload]) => payload as Uint8Array) } -describe('flushFileDocForExport', () => { - it('resolves only after the relay acknowledges a durable flush', async () => { - const emit = vi.fn( - (event: string, payload: { fileId: string }, acknowledge: (result: { ok: true }) => void) => { - expect(event).toBe(FILE_DOC_EVENTS.FLUSH) - expect(payload).toEqual({ fileId: 'file-1' }) - acknowledge({ ok: true }) - } - ) - const socket = { connected: true, emit } as unknown as Socket - - await expect(flushFileDocForExport(socket, 'file-1')).resolves.toBeUndefined() - }) - - it('surfaces a failed flush and a disconnected socket', async () => { - const emit = vi.fn( - (_event: string, _payload: unknown, acknowledge: (result: unknown) => void) => - acknowledge({ ok: false, error: 'Persist failed' }) - ) - await expect( - flushFileDocForExport({ connected: true, emit } as unknown as Socket, 'file-1') - ).rejects.toThrow('Persist failed') - await expect(flushFileDocForExport(null, 'file-1')).rejects.toThrow( - 'Connect to realtime before exporting your latest changes.' - ) - }) -}) - describe('FileDocProvider', () => { it('joins immediately with its client id when the socket is already connected', () => { const { doc, emit } = createProvider(true) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index c4b338ce897..84bab0733bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -7,7 +7,6 @@ import { FILE_DOC_MESSAGE_TYPE, FILE_DOC_SEED, FILE_DOC_TIMEOUTS, - type FlushFileDocResult, type JoinFileDocError, type JoinFileDocSuccess, toFileDocBytes, @@ -47,29 +46,6 @@ interface FileDocProviderEvents { */ const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs -/** - * Wait for the realtime relay to persist every file-doc update emitted before this call. Exports read - * the durable file blob, so editable clients use this acknowledgement immediately before requesting an - * export rather than racing the relay's normal edit debounce. - */ -export function flushFileDocForExport(socket: Socket | null, fileId: string): Promise { - if (!socket?.connected) { - return Promise.reject(new Error('Connect to realtime before exporting your latest changes.')) - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out while saving the latest document changes.')) - }, FILE_DOC_TIMEOUTS.flushAckMs) - - socket.emit(FILE_DOC_EVENTS.FLUSH, { fileId }, (result: FlushFileDocResult) => { - clearTimeout(timer) - if (result.ok) resolve() - else reject(new Error(result.error)) - }) - }) -} - /** * Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the * embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 9528472fc02..681061e9435 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -86,7 +86,6 @@ import { isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FileDocAvatars } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars' -import { flushFileDocForExport } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider' import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' @@ -107,7 +106,6 @@ import { import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { useSocket } from '@/app/workspace/providers/socket-provider' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { @@ -241,7 +239,6 @@ export function Files() { typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null const userPermissions = useUserPermissionsContext() const canEdit = userPermissions.canEdit === true - const { socket } = useSocket() const { config: permissionConfig } = usePermissionConfig() // Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the @@ -1075,7 +1072,6 @@ export function Files() { setPdfDownloadPending(true) } try { - if (isPdf && canEdit) await flushFileDocForExport(socket, file.id) await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { workspace_id: workspaceId, @@ -1092,7 +1088,7 @@ export function Files() { } } }, - [canEdit, socket, workspaceId] + [workspaceId] ) const deleteTargetRef = useRef(deleteTarget) diff --git a/packages/realtime-protocol/src/file-doc.test.ts b/packages/realtime-protocol/src/file-doc.test.ts index 93eb16745bd..48fd7db5b8a 100644 --- a/packages/realtime-protocol/src/file-doc.test.ts +++ b/packages/realtime-protocol/src/file-doc.test.ts @@ -9,7 +9,5 @@ describe('FILE_DOC_TIMEOUTS ordering invariants', () => { // The relay's `/seed` fetch must finish before the client's readiness deadline lapses into its // read-only fallback, or a late-but-successful seed can never reach the client. expect(FILE_DOC_TIMEOUTS.seedRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.readinessDeadlineMs) - // An export flush waits for the relay's durable `/persist` request to finish before acking. - expect(FILE_DOC_TIMEOUTS.persistRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.flushAckMs) }) }) diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts index 1fccbddb8be..3e50cf097df 100644 --- a/packages/realtime-protocol/src/file-doc.ts +++ b/packages/realtime-protocol/src/file-doc.ts @@ -23,8 +23,6 @@ export const FILE_DOC_EVENTS = { JOIN_ERROR: 'join-file-doc-error', /** Client → server: leave the session ({@link LeaveFileDocPayload}). */ LEAVE: 'leave-file-doc', - /** Client → server: durably persist the current live document before export. */ - FLUSH: 'flush-file-doc', /** Both directions: a framed Yjs message (binary), tagged by {@link FILE_DOC_MESSAGE_TYPE}. */ MESSAGE: 'file-doc-message', /** @@ -104,9 +102,10 @@ export const FILE_DOC_SEED = { * The seed request gets more headroom than the merge because it reads a (possibly cold) blob before * converting; the merge is a pure in-memory conversion the caller fully supplies. * - * `flushAckMs` (client → relay `flush`) wraps `persistRequestMs` (relay → app `/persist`), so - * `persistRequestMs < flushAckMs`. The acknowledged flush is used before exporting a live document; - * ordinary editing still persists on the relay's debounce and last-collaborator leave. + * `persistRequestMs` (relay → app `/persist`) stands alone — no client waits on it (the relay flushes + * the live doc to durable markdown debounced during editing and on the last collaborator leaving), so + * it forms no ordering invariant. It gets seed-level headroom because, like the seed, it crosses a + * durable blob write (Yjs → markdown → storage), not just an in-memory conversion. */ export const FILE_DOC_TIMEOUTS = { seedRequestMs: 8_000, @@ -114,7 +113,6 @@ export const FILE_DOC_TIMEOUTS = { applyEditMs: 6_000, readinessDeadlineMs: 12_000, persistRequestMs: 8_000, - flushAckMs: 10_000, } as const /** Client → server join request. `fileId` is the `workspace_files.id`. */ @@ -146,14 +144,6 @@ export interface LeaveFileDocPayload { fileId: string } -/** Client → server request to durably persist a joined live document before an export reads it. */ -export interface FlushFileDocPayload { - fileId: string -} - -/** Server acknowledgement for a {@link FlushFileDocPayload}. */ -export type FlushFileDocResult = { ok: true } | { ok: false; error: string } - /** One collaborator session in a {@link FileDocPresence} roster — server-authenticated identity. * Keyed per socket (session), not per user: the client excludes its OWN `socketId` and then * dedupes the rest per user for the avatar stack, so a second tab of the same account still From 9b975dd7a6a4d6f50341ada09b036ec03076f33c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 23:16:40 -0700 Subject: [PATCH 08/18] test(files): remove preview panel coverage --- .../components/file-viewer/preview-panel.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts deleted file mode 100644 index 35a79f13eef..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { resolvePreviewType } from './preview-panel' - -describe('resolvePreviewType', () => { - it.each([ - ['text/markdown', 'notes.txt'], - ['text/x-markdown', 'notes.txt'], - [null, 'notes.md'], - [null, 'notes.markdown'], - ])('uses the shared Markdown eligibility for %s / %s', (mimeType, filename) => { - expect(resolvePreviewType(mimeType, filename)).toBe('markdown') - }) -}) From 06b88959f63589a52bb9adc827ec5e7886838f96 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 23:33:52 -0700 Subject: [PATCH 09/18] refactor(files): isolate PDF export from Markdown behavior --- .../api/files/export/[id]/markdown-pdf.tsx | 13 +- .../app/api/files/export/[id]/route.test.ts | 450 ++++++++++-------- apps/sim/app/api/files/export/[id]/route.ts | 283 ++++++----- .../file-viewer/file-category.test.ts | 8 +- .../components/file-viewer/file-category.ts | 5 +- .../components/file-viewer/preview-panel.tsx | 14 +- apps/sim/lib/uploads/client/download.ts | 38 +- .../lib/uploads/server/inline-image.test.ts | 4 - apps/sim/lib/uploads/server/inline-image.ts | 12 +- .../uploads/utils/embedded-image-ref.test.ts | 32 -- .../lib/uploads/utils/embedded-image-ref.ts | 27 +- apps/sim/lib/uploads/utils/file-utils.test.ts | 3 +- apps/sim/lib/uploads/utils/file-utils.ts | 8 +- 13 files changed, 461 insertions(+), 436 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index a2535ad1c9c..283cd6dbd35 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -16,10 +16,19 @@ import { import type { JSONContent } from '@tiptap/core' import sharp from 'sharp' import { parseServerMarkdownToDoc } from '@/lib/collab-doc/server-markdown' -import { embeddedFileRefKey, extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +import { + type EmbeddedFileRef, + extractEmbeddedFileRef, +} from '@/lib/uploads/utils/embedded-image-ref' import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' type PdfImage = { data: Buffer; format: 'png' } +type ResolvedPdfImageRef = Exclude + +/** PDF-local map key for either embedded workspace-image reference spelling. */ +export function markdownPdfImageKey(ref: ResolvedPdfImageRef): string { + return 'key' in ref ? `key:${ref.key}` : `id:${ref.fileId}` +} interface GlyphFont { hasGlyphForCodePoint(codePoint: number): boolean @@ -426,7 +435,7 @@ function renderImage( ): ReactNode { const src = stringAttr(node, 'src') ?? '' const ref = extractEmbeddedFileRef(src) - const image = ref ? images.get(embeddedFileRefKey(ref)) : undefined + const image = ref ? images.get(markdownPdfImageKey(ref)) : undefined if (!image) { const alt = stringAttr(node, 'alt') return ( diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index 5f19013ff16..132d635a54c 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -11,9 +11,11 @@ const { mockGetFileMetadataById, mockVerifyFileAccess, mockDownloadFile, + mockExtractEmbeddedImageIds, + mockExtractEmbeddedFileRefs, mockResolveWorkspaceInlineImage, - mockRenderMarkdownPdf, mockEnforceUserRateLimit, + mockRenderMarkdownPdf, mockRecordAudit, mockCaptureServerEvent, MockMarkdownPdfLimitError, @@ -22,9 +24,11 @@ const { mockGetFileMetadataById: vi.fn(), mockVerifyFileAccess: vi.fn(), mockDownloadFile: vi.fn(), + mockExtractEmbeddedImageIds: vi.fn(), + mockExtractEmbeddedFileRefs: vi.fn(), mockResolveWorkspaceInlineImage: vi.fn(), - mockRenderMarkdownPdf: vi.fn(), mockEnforceUserRateLimit: vi.fn(), + mockRenderMarkdownPdf: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), MockMarkdownPdfLimitError: class extends Error {}, @@ -36,16 +40,24 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) +vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ + extractEmbeddedImageIds: mockExtractEmbeddedImageIds, +})) +vi.mock('@/lib/uploads/utils/embedded-image-ref', () => ({ + extractEmbeddedFileRefs: mockExtractEmbeddedFileRefs, +})) vi.mock('@/lib/uploads/server/inline-image', () => ({ resolveWorkspaceInlineImage: mockResolveWorkspaceInlineImage, })) -vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ - renderMarkdownPdf: mockRenderMarkdownPdf, - MarkdownPdfLimitError: MockMarkdownPdfLimitError, -})) vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, })) +vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ + MarkdownPdfLimitError: MockMarkdownPdfLimitError, + markdownPdfImageKey: (ref: { key?: string; fileId?: string }) => + ref.key ? `key:${ref.key}` : `id:${ref.fileId}`, + renderMarkdownPdf: mockRenderMarkdownPdf, +})) vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, @@ -60,7 +72,7 @@ const DOC_ID = 'doc-1' const context = { params: Promise.resolve({ id: DOC_ID }) } function request(format?: 'pdf') { - const query = format ? `?format=${format}` : '' + const query = format ? '?format=pdf' : '' return createMockRequest( 'GET', undefined, @@ -69,189 +81,118 @@ function request(format?: 'pdf') { ) } -function inlineImage(id: string, size = 1 * MB) { +function assetRecord(id: string, size: number) { return { + id, key: `workspace/ws-1/${id}`, - filename: id.endsWith('.png') ? id : `${id}.png`, + originalName: `${id}.png`, contentType: 'image/png', + context: 'workspace', size, + workspaceId: 'ws-1', } } -function markdownWithIds(...ids: string[]): Buffer { - return Buffer.from(ids.map((id) => `![${id}](/api/files/view/${id})`).join('\n')) -} - describe('markdown export bundling', () => { beforeEach(() => { vi.clearAllMocks() mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' }) mockVerifyFileAccess.mockResolvedValue(true) - mockGetFileMetadataById.mockResolvedValue({ - id: DOC_ID, - key: 'workspace/ws-1/doc.md', - originalName: 'doc.md', - contentType: 'text/markdown', - context: 'workspace', - size: 1024, - workspaceId: 'ws-1', - }) - mockResolveWorkspaceInlineImage.mockImplementation( - async (_workspaceId: string, ref: { fileId?: string; key?: string }) => { - const id = ref.fileId ?? ref.key?.split('/').at(-1) ?? 'image' - return inlineImage(id) - } + mockGetFileMetadataById.mockImplementation(async (id: string) => + id === DOC_ID + ? { + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + } + : assetRecord(id, 1 * MB) ) mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) - mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + mockExtractEmbeddedImageIds.mockReturnValue([]) + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: [] }) + mockResolveWorkspaceInlineImage.mockResolvedValue(null) mockEnforceUserRateLimit.mockResolvedValue(null) + mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) }) - it('returns the stored Markdown unchanged when no format is requested', async () => { + it('returns stored Markdown unchanged when no embedded image IDs exist', async () => { + const markdown = '# Doc\n![editor image](/api/files/serve/workspace%2Fws-1%2Fimage.png)\n' + mockDownloadFile.mockResolvedValue(Buffer.from(markdown)) + const response = await GET(request(), context) expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8') expect(response.headers.get('Content-Disposition')).toContain('doc.md') - expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('# Doc\n') + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe(markdown) + expect(mockExtractEmbeddedFileRefs).not.toHaveBeenCalled() + expect(mockResolveWorkspaceInlineImage).not.toHaveBeenCalled() expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() expect(mockEnforceUserRateLimit).not.toHaveBeenCalled() }) - it('renders Markdown as a directly downloadable PDF', async () => { - const response = await GET(request('pdf'), context) - - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('application/pdf') - expect(response.headers.get('Content-Disposition')).toContain('doc.pdf') - expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('%PDF-generated') - expect(mockRenderMarkdownPdf).toHaveBeenCalledWith({ - markdown: '# Doc\n', - title: 'doc', - images: expect.any(Map), - }) - expect(mockRenderMarkdownPdf.mock.calls[0][0].images.size).toBe(0) - expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('markdown-pdf-export', 'user-1', { - maxTokens: 3, - refillRate: 3, - refillIntervalMs: 60_000, - }) - }) - - it('stops a rate-limited PDF export before reading the document', async () => { - mockEnforceUserRateLimit.mockResolvedValue( - new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 }) - ) - - const response = await GET(request('pdf'), context) - - expect(response.status).toBe(429) - expect(mockDownloadFile).not.toHaveBeenCalled() - expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() - }) - - it('returns a clear rejection when the parsed document exceeds renderer limits', async () => { - mockRenderMarkdownPdf.mockRejectedValue( - new MockMarkdownPdfLimitError('This document is too complex to export as PDF.') - ) - - const response = await GET(request('pdf'), context) - - expect(response.status).toBe(400) - expect((await response.json()).error).toContain('too complex') - expect(mockRecordAudit).not.toHaveBeenCalled() - }) - - it('passes only authorized, readable embedded images to the PDF renderer', async () => { - mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return markdownWithIds('good', 'secret', 'broken') - if (key.endsWith('broken')) throw new Error('storage down') - return Buffer.from('png-bytes') - }) - - const response = await GET(request('pdf'), context) - - expect(response.status).toBe(200) - const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map - expect(Array.from(images.keys())).toEqual(['id:good']) - expect(images.get('id:good')).toEqual(Buffer.from('png-bytes')) - }) - - it('resolves the key-based image URL emitted by the Files editor', async () => { - const key = 'workspace/ws-1/editor-image.png' - mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => - requestedKey.endsWith('doc.md') - ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) + it('preserves image-ID ZIP rewriting and bulk telemetry without a format', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') + ? Buffer.from('![image](/api/files/view/image-1)') : Buffer.from('png-bytes') ) - const response = await GET(request('pdf'), context) + const response = await GET(request(), context) expect(response.status).toBe(200) - expect(mockResolveWorkspaceInlineImage).toHaveBeenCalledWith('ws-1', { key }) - const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map - expect(images.get(`key:${key}`)).toEqual(Buffer.from('png-bytes')) - }) - - it('records an image-containing PDF as one downloaded file', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') - ) - - await GET(request('pdf'), context) - - expect(mockRecordAudit).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ assetCount: 1, format: 'pdf' }), - }) - ) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'file_downloaded', - expect.objectContaining({ file_count: 1, is_bulk: false }), - { groups: { workspace: 'ws-1' } } - ) - }) - - it('keeps image-containing ZIP telemetry bulk', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') - ) - - await GET(request(), context) - + expect(response.headers.get('Content-Type')).toBe('application/zip') + const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) + expect(await zip.file('doc.md')?.async('string')).toBe('![image](./assets/image-1.png)') + expect(zip.file('assets/image-1.png')).not.toBeNull() expect(mockCaptureServerEvent).toHaveBeenCalledWith( 'user-1', 'file_downloaded', expect.objectContaining({ file_count: 2, is_bulk: true }), { groups: { workspace: 'ws-1' } } ) + expect(mockExtractEmbeddedFileRefs).not.toHaveBeenCalled() }) - it('rejects PDF format for a non-Markdown file', async () => { + it('preserves the non-Markdown serve redirect without a format', async () => { mockGetFileMetadataById.mockResolvedValue({ id: DOC_ID, - key: 'workspace/ws-1/doc.txt', - originalName: 'doc.txt', - contentType: 'text/plain', + key: 'workspace/ws-1/image.png', + originalName: 'image.png', + contentType: 'image/png', context: 'workspace', size: 1024, workspaceId: 'ws-1', }) - const response = await GET(request('pdf'), context) + const response = await GET(request(), context) - expect(response.status).toBe(400) - expect((await response.json()).error).toContain('only available for Markdown') + expect(response.status).toBe(302) + expect(response.headers.get('Location')).toContain('/api/files/serve/') expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockEnforceUserRateLimit).not.toHaveBeenCalled() expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) it('rejects on declared asset bytes before downloading any of them', async () => { - mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b', 'c')) - mockResolveWorkspaceInlineImage.mockImplementation( - async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 100 * MB) + mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c']) + mockGetFileMetadataById.mockImplementation(async (id: string) => + id === DOC_ID + ? { + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + } + : assetRecord(id, 100 * MB) ) const response = await GET(request(), context) @@ -264,9 +205,8 @@ describe('markdown export bundling', () => { it('counts the document body against the export limit, not just its assets', async () => { // Assets alone sit under the cap; the body is what carries the bundle over it. - const body = Buffer.alloc(250 * MB) - markdownWithIds('a').copy(body) - mockDownloadFile.mockResolvedValue(body) + mockExtractEmbeddedImageIds.mockReturnValue(['a']) + mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB)) const response = await GET(request(), context) @@ -275,32 +215,16 @@ describe('markdown export bundling', () => { }) it('caps the document body read rather than loading it unbounded', async () => { + mockExtractEmbeddedImageIds.mockReturnValue([]) + await GET(request(), context) const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) expect(bodyCall?.[0].maxBytes).toBe(250 * MB) }) - it('uses a smaller document limit for PDF rendering', async () => { - await GET(request('pdf'), context) - - const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) - expect(bodyCall?.[0].maxBytes).toBe(256 * 1024) - }) - - it('reports an oversized PDF body with the PDF-specific limit', async () => { - mockDownloadFile.mockRejectedValue( - new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) - ) - - const response = await GET(request('pdf'), context) - - expect(response.status).toBe(400) - expect((await response.json()).error).toContain('256 KB PDF export limit') - expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() - }) - it('reports an oversized body as a size rejection, not a server error', async () => { + mockExtractEmbeddedImageIds.mockReturnValue([]) mockDownloadFile.mockRejectedValue( new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) ) @@ -313,9 +237,7 @@ describe('markdown export bundling', () => { }) it('caps each asset download rather than trusting its declared size', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') - ) + mockExtractEmbeddedImageIds.mockReturnValue(['a']) await GET(request(), context) @@ -325,88 +247,194 @@ describe('markdown export bundling', () => { expect(assetCall?.[0].maxBytes).toBe(25 * MB) }) - it('uses a smaller per-asset limit for PDF rendering', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') - ) + it('drops an unreadable asset instead of failing the whole export', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { + if (key.endsWith('doc.md')) return Buffer.from('# Doc\n![x](/api/files/view/good)\n') + if (key.endsWith('bad')) throw new Error('storage down') + return Buffer.from('png-bytes') + }) - await GET(request('pdf'), context) + const response = await GET(request(), context) - const assetCall = mockDownloadFile.mock.calls.find( - ([options]) => options.key === 'workspace/ws-1/a' - ) - expect(assetCall?.[0].maxBytes).toBe(10 * MB) + expect(response.status).toBe(200) + const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) + expect(zip.file('assets/good.png')).not.toBeNull() + expect(zip.file('assets/bad.png')).toBeNull() }) - it('rejects PDF source material above its aggregate input limit', async () => { - mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b')) - mockResolveWorkspaceInlineImage.mockImplementation( - async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 30 * MB) + it('skips an asset the caller cannot read', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['secret']) + mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + + const response = await GET(request(), context) + + expect(response.status).toBe(200) + // Authorization is settled during metadata resolution, before any asset read. + expect(mockDownloadFile.mock.calls.some(([options]) => options.key.endsWith('secret'))).toBe( + false ) + }) +}) + +describe('markdown PDF export', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' }) + mockVerifyFileAccess.mockResolvedValue(true) + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) + mockExtractEmbeddedImageIds.mockReturnValue([]) + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: [] }) + mockResolveWorkspaceInlineImage.mockResolvedValue(null) + mockEnforceUserRateLimit.mockResolvedValue(null) + mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + }) + it('renders a direct PDF attachment with the Markdown filename', async () => { const response = await GET(request('pdf'), context) - expect(response.status).toBe(400) - expect((await response.json()).error).toContain('50 MB PDF export limit') - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Content-Disposition')).toContain('doc.pdf') + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('%PDF-generated') + expect(mockRenderMarkdownPdf).toHaveBeenCalledWith({ + markdown: '# Doc\n', + title: 'doc', + images: expect.any(Map), + }) + expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('markdown-pdf-export', 'user-1', { + maxTokens: 3, + refillRate: 3, + refillIntervalMs: 60_000, + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + expect.objectContaining({ file_count: 1, is_bulk: false }), + { groups: { workspace: 'ws-1' } } + ) }) - it('enforces the aggregate PDF budget against downloaded bytes, not only metadata', async () => { - const ids = ['a', 'b', 'c', 'd', 'e', 'f'] + it('resolves authorized key- and ID-based images for the PDF renderer', async () => { + const imageKey = 'workspace/ws-1/editor-image.png' + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [imageKey], ids: ['image-1'] }) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { key?: string; fileId?: string }) => ({ + key: ref.key ?? `workspace/ws-1/${ref.fileId}`, + filename: 'image.png', + contentType: 'image/png', + }) + ) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds(...ids) : Buffer.alloc(10 * MB) + key.endsWith('doc.md') ? Buffer.from('# Doc\n') : Buffer.from(`bytes:${key}`) ) const response = await GET(request('pdf'), context) expect(response.status).toBe(200) + expect(mockResolveWorkspaceInlineImage).toHaveBeenNthCalledWith(1, 'ws-1', { key: imageKey }) + expect(mockResolveWorkspaceInlineImage).toHaveBeenNthCalledWith(2, 'ws-1', { + fileId: 'image-1', + }) const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map - expect(images.size).toBe(4) + expect(images.get(`key:${imageKey}`)).toEqual(Buffer.from(`bytes:${imageKey}`)) + expect(images.get('id:image-1')).toEqual(Buffer.from('bytes:workspace/ws-1/image-1')) + expect(mockExtractEmbeddedImageIds).not.toHaveBeenCalled() }) - it('rewrites the editor key URL when producing a Markdown asset ZIP', async () => { - const key = 'workspace/ws-1/editor-image.png' - mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => - requestedKey.endsWith('doc.md') - ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) - : Buffer.from('png-bytes') + it('rejects PDF format for a non-Markdown file', async () => { + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/doc.txt', + originalName: 'doc.txt', + contentType: 'text/plain', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('only available for Markdown') + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + + it('stops a rate-limited PDF export before reading the document', async () => { + mockEnforceUserRateLimit.mockResolvedValue( + new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 }) ) - const response = await GET(request(), context) + const response = await GET(request('pdf'), context) - const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) - expect(await zip.file('doc.md')?.async('string')).toBe('![image](./assets/editor-image.png)') - expect(zip.file('assets/editor-image.png')).not.toBeNull() + expect(response.status).toBe(429) + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) - it('drops an unreadable asset instead of failing the whole export', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return markdownWithIds('good', 'bad') - if (key.endsWith('bad')) throw new Error('storage down') - return Buffer.from('png-bytes') + it('uses PDF-specific document and image byte limits', async () => { + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: ['image-1'] }) + mockResolveWorkspaceInlineImage.mockResolvedValue({ + key: 'workspace/ws-1/image-1', + filename: 'image.png', + contentType: 'image/png', }) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? Buffer.from('# Doc\n') : Buffer.from('image') + ) - const response = await GET(request(), context) + const response = await GET(request('pdf'), context) expect(response.status).toBe(200) - const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) - expect(zip.file('assets/good.png')).not.toBeNull() - expect(zip.file('assets/bad.png')).toBeNull() + const documentCall = mockDownloadFile.mock.calls.find(([options]) => + options.key.endsWith('doc.md') + ) + const imageCall = mockDownloadFile.mock.calls.find( + ([options]) => options.key === 'workspace/ws-1/image-1' + ) + expect(documentCall?.[0].maxBytes).toBe(256 * 1024) + expect(imageCall?.[0].maxBytes).toBe(10 * MB) }) - it('skips an asset the caller cannot read', async () => { - mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + it('rejects actual downloaded image bytes above the aggregate PDF budget', async () => { + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: ['a', 'b'] }) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId: string }) => ({ + key: `workspace/ws-1/${ref.fileId}`, + filename: `${ref.fileId}.png`, + contentType: 'image/png', + }) + ) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('secret') : Buffer.from('asset') + key.endsWith('doc.md') ? Buffer.from('# Doc\n') : Buffer.alloc(26 * MB) ) - const response = await GET(request(), context) + const response = await GET(request('pdf'), context) - expect(response.status).toBe(200) - // Authorization is settled during metadata resolution, before any asset read. - expect(mockDownloadFile.mock.calls.some(([options]) => options.key.endsWith('secret'))).toBe( - false + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('50 MB PDF export limit') + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + + it('returns renderer resource-limit errors as clear client errors', async () => { + mockRenderMarkdownPdf.mockRejectedValue( + new MockMarkdownPdfLimitError('This document is too complex to export as PDF.') ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('too complex') + expect(mockRecordAudit).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 5f75aef2d74..3af7478852a 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -8,6 +8,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 type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' @@ -19,13 +20,8 @@ import { getServeStoragePrefix } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' import { getFileMetadataById } from '@/lib/uploads/server/metadata' -import { - embeddedFileRefKey, - extractEmbeddedFileRefs, - type ResolvedEmbeddedFileRef, - replaceEmbeddedFileRefs, -} from '@/lib/uploads/utils/embedded-image-ref' -import { formatFileSize, isMarkdownFile } from '@/lib/uploads/utils/file-utils' +import { extractEmbeddedFileRefs } from '@/lib/uploads/utils/embedded-image-ref' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { encodeFilenameForHeader } from '@/app/api/files/utils' @@ -42,18 +38,25 @@ const logger = createLogger('FilesExportAPI') */ const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 -/** PDF-specific document ceiling that bounds parser and layout-tree work. */ + const MAX_PDF_MARKDOWN_BYTES = 256 * 1024 const MAX_PDF_ASSET_BYTES = 10 * 1024 * 1024 const MAX_PDF_TOTAL_SOURCE_BYTES = 50 * 1024 * 1024 - -/** PDF rendering is CPU-bound and buffers its result, so it gets a narrower request bucket than downloads. */ const PDF_EXPORT_RATE_LIMIT: TokenBucketConfig = { maxTokens: 3, refillRate: 3, refillIntervalMs: 60_000, } +const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) +const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) + +function isMarkdown(originalName: string, contentType: string): boolean { + if (MARKDOWN_MIME_TYPES.has(contentType)) return true + const ext = originalName.split('.').pop()?.toLowerCase() ?? '' + return MARKDOWN_EXTENSIONS.has(ext) +} + function safeFilename(name: string): string { return path .basename(name) @@ -61,13 +64,13 @@ function safeFilename(name: string): string { .replace(/[\r\n\t]/g, '') } -function deduplicatedFilename(preferred: string, existing: Set): string { +function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { if (!existing.has(preferred)) return preferred const ext = path.extname(preferred) const base = path.basename(preferred, ext) - let suffix = 2 - while (existing.has(`${base}_${suffix}${ext}`)) suffix += 1 - return `${base}_${suffix}${ext}` + const short = `${base}_${imageId.slice(0, 8)}${ext}` + if (!existing.has(short)) return short + return `${base}_${imageId}${ext}` } export const GET = withRouteHandler( @@ -101,7 +104,7 @@ export const GET = withRouteHandler( * markdown, or bundled zip) so a mid-export failure never logs a download * that never happened. */ - const auditExport = (format: 'file' | 'markdown' | 'pdf' | 'zip', assetCount: number) => { + const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => { recordAudit({ workspaceId: record.workspaceId ?? null, actorId: userId, @@ -119,83 +122,123 @@ export const GET = withRouteHandler( }, request, }) - const downloadedFileCount = format === 'zip' ? 1 + assetCount : 1 captureServerEvent( userId, 'file_downloaded', { ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), - is_bulk: downloadedFileCount > 1, - file_count: downloadedFileCount, + is_bulk: assetCount > 0, + file_count: 1 + assetCount, + }, + record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined + ) + } + + const auditPdfExport = (assetCount: number) => { + recordAudit({ + workspaceId: record.workspaceId ?? null, + actorId: userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: record.id, + resourceName: record.originalName, + description: `Exported file "${record.originalName}"`, + metadata: { + fileId: record.id, + fileName: record.originalName, + bytes: record.size, + format: 'pdf', + assetCount, + }, + request, + }) + captureServerEvent( + userId, + 'file_downloaded', + { + ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), + is_bulk: false, + file_count: 1, }, record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined ) } - if (!isMarkdownFile({ name: record.originalName, type: record.contentType })) { - if (format === 'pdf') { + if (format === 'pdf') { + if (!isMarkdown(record.originalName, record.contentType)) { return NextResponse.json( { error: 'PDF export is only available for Markdown files.' }, { status: 400 } ) } - const storagePrefix = getServeStoragePrefix() - const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` - auditExport('file', 0) - return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) - } - if (format === 'pdf') { const rateLimited = await enforceUserRateLimit( 'markdown-pdf-export', userId, PDF_EXPORT_RATE_LIMIT ) if (rateLimited) return rateLimited - } - // Capped like everything else in the bundle: the document body is usually the - // largest single entry, so leaving it unbounded left the export limit unenforced - // against the one item most able to exceed it. A body that alone exceeds the limit - // is a size rejection, so it reports as one rather than as a server error. - let mdBuffer: Buffer - const documentLimit = format === 'pdf' ? MAX_PDF_MARKDOWN_BYTES : MAX_EXPORT_TOTAL_BYTES - try { - mdBuffer = await downloadFile({ - key: record.key, - context: record.context as StorageContext, - maxBytes: documentLimit, - }) - } catch (error) { - if (!isPayloadSizeLimitError(error)) throw error - return NextResponse.json( - { - error: - format === 'pdf' - ? `This document exceeds the ${formatFileSize(MAX_PDF_MARKDOWN_BYTES)} PDF export limit.` - : `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, - }, - { status: 400 } + let mdBuffer: Buffer + try { + mdBuffer = await downloadFile({ + key: record.key, + context: record.context as StorageContext, + maxBytes: MAX_PDF_MARKDOWN_BYTES, + }) + } catch (error) { + if (!isPayloadSizeLimitError(error)) throw error + return NextResponse.json( + { + error: `This document exceeds the ${formatFileSize(MAX_PDF_MARKDOWN_BYTES)} PDF export limit.`, + }, + { status: 400 } + ) + } + + const mdContent = mdBuffer.toString('utf-8') + const { keys: imageKeys, ids: imageIds } = extractEmbeddedFileRefs(mdContent) + const imageRefs: Array<{ key: string } | { fileId: string }> = [ + ...imageKeys.map((key) => ({ key })), + ...imageIds.map((fileId) => ({ fileId })), + ] + const { MarkdownPdfLimitError, markdownPdfImageKey, renderMarkdownPdf } = await import( + '@/app/api/files/export/[id]/markdown-pdf' ) - } - let mdContent = mdBuffer.toString('utf-8') + const images = new Map() + let sourceBytes = mdBuffer.length - const { keys: imageKeys, ids: imageIds } = extractEmbeddedFileRefs(mdContent) - const imageRefs: ResolvedEmbeddedFileRef[] = [ - ...imageKeys.map((key) => ({ key })), - ...imageIds.map((fileId) => ({ fileId })), - ] + if (record.workspaceId) { + for (const imageRef of imageRefs) { + try { + const image = await resolveWorkspaceInlineImage(record.workspaceId, imageRef) + if (!image || !(await verifyFileAccess(image.key, userId))) continue - logger.info('Exporting markdown', { - id, - format: format ?? 'source', - imageCount: imageRefs.length, - }) + const buffer = await downloadFile({ + key: image.key, + context: 'workspace', + maxBytes: MAX_PDF_ASSET_BYTES, + }) + if (sourceBytes + buffer.length > MAX_PDF_TOTAL_SOURCE_BYTES) { + return NextResponse.json( + { + error: `This document and its embedded files exceed the ${formatFileSize(MAX_PDF_TOTAL_SOURCE_BYTES)} PDF export limit.`, + }, + { status: 400 } + ) + } + + sourceBytes += buffer.length + images.set(markdownPdfImageKey(imageRef), buffer) + } catch (error) { + logger.warn('Failed to fetch asset for PDF export', { + imageRef: markdownPdfImageKey(imageRef), + error: toError(error).message, + }) + } + } + } - const respondWithPdf = async (images: ReadonlyMap) => { - const { MarkdownPdfLimitError, renderMarkdownPdf } = await import( - '@/app/api/files/export/[id]/markdown-pdf' - ) const title = record.originalName.replace(/\.(?:md|markdown)$/i, '') const pdfName = safeFilename(`${title}.pdf`) let pdfBuffer: Buffer @@ -207,7 +250,8 @@ export const GET = withRouteHandler( } throw error } - auditExport('pdf', images.size) + + auditPdfExport(images.size) return new NextResponse(new Uint8Array(pdfBuffer), { status: 200, headers: { @@ -218,8 +262,40 @@ export const GET = withRouteHandler( }) } - if (imageRefs.length === 0) { - if (format === 'pdf') return respondWithPdf(new Map()) + if (!isMarkdown(record.originalName, record.contentType)) { + const storagePrefix = getServeStoragePrefix() + const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` + auditExport('file', 0) + return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) + } + + // Capped like everything else in the bundle: the document body is usually the + // largest single entry, so leaving it unbounded left the export limit unenforced + // against the one item most able to exceed it. A body that alone exceeds the limit + // is a size rejection, so it reports as one rather than as a server error. + let mdBuffer: Buffer + try { + mdBuffer = await downloadFile({ + key: record.key, + context: record.context as StorageContext, + maxBytes: MAX_EXPORT_TOTAL_BYTES, + }) + } catch (error) { + if (!isPayloadSizeLimitError(error)) throw error + return NextResponse.json( + { + error: `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, + }, + { status: 400 } + ) + } + let mdContent = mdBuffer.toString('utf-8') + + const imageIds = extractEmbeddedImageIds(mdContent) + + logger.info('Exporting markdown', { id, imageCount: imageIds.length }) + + if (imageIds.length === 0) { const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') auditExport('markdown', 0) @@ -236,15 +312,15 @@ export const GET = withRouteHandler( // Metadata first: declared sizes bound the download before a byte is read, and the // authorization check costs nothing to run here. const assetTargets = ( - await mapWithConcurrency(imageRefs, MATERIALIZE_CONCURRENCY, async (ref) => { + await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => { try { - if (!record.workspaceId) return null - const image = await resolveWorkspaceInlineImage(record.workspaceId, ref) - if (!image || !(await verifyFileAccess(image.key, userId))) return null - return { imageKey: embeddedFileRefKey(ref), image } + const imgRecord = await getFileMetadataById(imageId) + if (!imgRecord) return null + if (!(await verifyFileAccess(imgRecord.key, userId))) return null + return { imageId, record: imgRecord } } catch (error) { logger.warn('Failed to resolve asset for export', { - imageRef: embeddedFileRefKey(ref), + imageId, error: toError(error).message, }) return null @@ -255,44 +331,32 @@ export const GET = withRouteHandler( // The body counts against the same budget as its assets — the zip holds both, so a // limit that measured only the attachments would not describe the archive produced. const bundleBytes = - mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.image.size, 0) - const bundleLimit = format === 'pdf' ? MAX_PDF_TOTAL_SOURCE_BYTES : MAX_EXPORT_TOTAL_BYTES - if (bundleBytes > bundleLimit) { + mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0) + if (bundleBytes > MAX_EXPORT_TOTAL_BYTES) { return NextResponse.json( { - error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(bundleLimit)} ${format === 'pdf' ? 'PDF ' : ''}export limit.`, + error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, }, { status: 400 } ) } - let actualBundleBytes = mdBuffer.length const fetched = await mapWithConcurrency( assetTargets, - // PDF assets stay sequential so the actual-byte budget also bounds peak retained buffers; - // ZIP keeps the existing shared materialization concurrency. - format === 'pdf' ? 1 : MATERIALIZE_CONCURRENCY, - async ({ imageKey, image }) => { + MATERIALIZE_CONCURRENCY, + async ({ imageId, record: imgRecord }) => { try { const buffer = await downloadFile({ - key: image.key, - context: 'workspace', - maxBytes: format === 'pdf' ? MAX_PDF_ASSET_BYTES : MAX_EXPORT_ASSET_BYTES, + key: imgRecord.key, + context: imgRecord.context as StorageContext, + maxBytes: MAX_EXPORT_ASSET_BYTES, }) - if (actualBundleBytes + buffer.length > bundleLimit) { - logger.warn('Skipping asset that exceeds the actual export byte budget', { - imageRef: imageKey, - bundleLimit, - }) - return null - } - actualBundleBytes += buffer.length - return { imageKey, originalName: image.filename, buffer } + return { imageId, originalName: imgRecord.originalName, buffer } } catch (error) { // A single unreadable or oversized asset drops out of the bundle rather than // failing the whole export; the markdown keeps its original link. logger.warn('Failed to fetch asset for export', { - imageRef: imageKey, + imageId, error: toError(error).message, }) return null @@ -300,30 +364,27 @@ export const GET = withRouteHandler( } ) - if (format === 'pdf') { - return respondWithPdf( - new Map( - fetched.flatMap((result) => (result ? [[result.imageKey, result.buffer] as const] : [])) - ) - ) - } - const assetMap = new Map() const usedFilenames = new Set() for (const result of fetched) { if (!result) continue - const { imageKey, originalName, buffer } = result + const { imageId, originalName, buffer } = result const preferred = safeFilename(originalName) - const filename = deduplicatedFilename(preferred, usedFilenames) + const filename = deduplicatedFilename(preferred, usedFilenames, imageId) usedFilenames.add(filename) - assetMap.set(imageKey, { filename, buffer }) + assetMap.set(imageId, { filename, buffer }) } - mdContent = replaceEmbeddedFileRefs( - mdContent, - new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, `./assets/${asset.filename}`])) - ) + for (const [imageId, asset] of assetMap) { + const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const replacement = `./assets/${asset.filename}` + // Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app + // `/workspace//files/` 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() zip.file(safeFilename(record.originalName), mdContent) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts index af19cb6d3f3..00480f609dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts @@ -12,11 +12,6 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({ const lastDot = filename.lastIndexOf('.') return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' }, - isMarkdownFile: (file: { type?: string | null; name: string }): boolean => { - if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true - const extension = file.name.split('.').at(-1)?.toLowerCase() - return extension === 'md' || extension === 'markdown' - }, })) import { resolveFileCategory } from './file-category' @@ -26,7 +21,6 @@ describe('resolveFileCategory — MIME type routing', () => { it.each([ 'text/plain', 'text/markdown', - 'text/x-markdown', 'application/json', 'application/x-yaml', 'text/csv', @@ -136,7 +130,7 @@ describe('resolveFileCategory — MIME type routing', () => { describe('resolveFileCategory — extension fallback', () => { describe('text-editable extensions', () => { - it.each(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( + it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( '.%s → text-editable', (ext) => { expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts index 6683bdeabd8..485ebdeda56 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts @@ -1,7 +1,8 @@ -import { getFileExtension, isMarkdownFile } from '@/lib/uploads/utils/file-utils' +import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' const TEXT_EDITABLE_MIME_TYPES = new Set([ + 'text/markdown', 'text/plain', 'application/json', 'application/x-yaml', @@ -22,6 +23,7 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([ ]) const TEXT_EDITABLE_EXTENSIONS = new Set([ + 'md', 'txt', 'json', 'yaml', @@ -126,7 +128,6 @@ export type FileCategory = | 'unsupported' export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { - if (isMarkdownFile({ type: mimeType, name: filename })) return 'text-editable' if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index c932b70d63b..2b5b9c997d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -3,10 +3,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import '@sim/emcn/components/code/code.css' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' -import { - getFileExtension, - isMarkdownFile as isSharedMarkdownFile, -} from '@/lib/uploads/utils/file-utils' +import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import' import { DataTable } from './data-table' import { MermaidDiagram } from './mermaid-diagram' @@ -15,6 +12,7 @@ import { ZoomablePreview } from './zoomable-preview' type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null const PREVIEWABLE_MIME_TYPES: Record = { + 'text/markdown': 'markdown', 'text/html': 'html', 'text/csv': 'csv', 'image/svg+xml': 'svg', @@ -22,6 +20,7 @@ const PREVIEWABLE_MIME_TYPES: Record = { } const PREVIEWABLE_EXTENSIONS: Record = { + md: 'markdown', html: 'html', htm: 'html', csv: 'csv', @@ -30,14 +29,9 @@ const PREVIEWABLE_EXTENSIONS: Record = { } /** All extensions that have a rich preview renderer. */ -export const RICH_PREVIEWABLE_EXTENSIONS = new Set([ - ...Object.keys(PREVIEWABLE_EXTENSIONS), - 'md', - 'markdown', -]) +export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS)) export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType { - if (isSharedMarkdownFile({ type: mimeType, name: filename })) return 'markdown' if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType] const ext = getFileExtension(filename) return PREVIEWABLE_EXTENSIONS[ext] ?? null diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index a6ad0fd0f24..8995a7effe6 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -2,7 +2,6 @@ import { requestRaw } from '@/lib/api/client/request' import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' export function saveBlob(blob: Blob, fileName: string): void { const objectUrl = URL.createObjectURL(blob) @@ -36,29 +35,32 @@ export async function triggerFileDownload( record: WorkspaceFileRecord, options?: { format?: 'pdf' } ): Promise { - const isMarkdown = isMarkdownFile(record) + const isMarkdown = + record.type === 'text/markdown' || + record.type === 'text/x-markdown' || + /\.(?:md|markdown)$/i.test(record.name) - if (options?.format === 'pdf' && !isMarkdown) { - throw new Error('PDF export is only available for Markdown files') - } - - let response: Response - if (isMarkdown) { - response = await requestRaw( + if (options?.format === 'pdf') { + if (!isMarkdown) throw new Error('PDF export is only available for Markdown files') + const response = await requestRaw( fileExportContract, - { params: { id: record.id }, query: { format: options?.format } }, + { params: { id: record.id }, query: { format: 'pdf' } }, { cache: 'no-store' } ) - } else { - const url = `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` - // boundary-raw-fetch: legacy binary serve URL includes context and cache-busting query fields outside the serve contract - response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + const fallbackName = `${record.name.replace(/\.[^.]+$/, '')}.pdf` + saveBlob(await response.blob(), fileNameFromDisposition(response, fallbackName)) + return } - const fallbackName = - options?.format === 'pdf' ? `${record.name.replace(/\.[^.]+$/, '')}.pdf` : record.name - saveBlob(await response.blob(), fileNameFromDisposition(response, fallbackName)) + const url = isMarkdown + ? `/api/files/export/${encodeURIComponent(record.id)}` + : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` + + // boundary-raw-fetch: binary download read as a blob; these paths have no contract + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + + saveBlob(await response.blob(), fileNameFromDisposition(response, record.name)) } /** diff --git a/apps/sim/lib/uploads/server/inline-image.test.ts b/apps/sim/lib/uploads/server/inline-image.test.ts index db5eb6da131..ba774a3e8f1 100644 --- a/apps/sim/lib/uploads/server/inline-image.test.ts +++ b/apps/sim/lib/uploads/server/inline-image.test.ts @@ -21,7 +21,6 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', type: 'image/png', name: 'x.png', - size: 123, }) const out = await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' }) expect(mockGetWorkspaceFile).toHaveBeenCalledWith('ws-1', 'wf_a') @@ -29,7 +28,6 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', contentType: 'image/png', filename: 'x.png', - size: 123, }) }) @@ -44,14 +42,12 @@ describe('resolveWorkspaceInlineImage', () => { workspaceId: 'ws-1', contentType: 'image/png', originalName: 'x.png', - size: 456, }) 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', - size: 456, }) }) diff --git a/apps/sim/lib/uploads/server/inline-image.ts b/apps/sim/lib/uploads/server/inline-image.ts index 918e2a9b54d..b44b9e08e4a 100644 --- a/apps/sim/lib/uploads/server/inline-image.ts +++ b/apps/sim/lib/uploads/server/inline-image.ts @@ -15,7 +15,6 @@ export interface ResolvedInlineImage { key: string contentType: string filename: string - size: number } /** @@ -31,19 +30,12 @@ export async function resolveWorkspaceInlineImage( ): Promise { if (ref.fileId) { const file = await getWorkspaceFile(workspaceId, ref.fileId) - return file - ? { key: file.key, contentType: file.type, filename: file.name, size: file.size } - : null + 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, - size: record.size, - } + return { key: record.key, contentType: record.contentType, filename: record.originalName } } return null } diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts index e13bf554052..bec0f9e936b 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest' import { - embeddedFileRefKey, extractEmbeddedFileRef, extractEmbeddedFileRefs, - replaceEmbeddedFileRefs, } from '@/lib/uploads/utils/embedded-image-ref' const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' @@ -33,13 +31,6 @@ describe('extractEmbeddedFileRef', () => { }) }) -describe('embeddedFileRefKey', () => { - it('keeps storage keys and file ids in distinct map namespaces', () => { - expect(embeddedFileRefKey({ key: KEY })).toBe(`key:${KEY}`) - expect(embeddedFileRefKey({ fileId: 'wf_abc' })).toBe('id:wf_abc') - }) -}) - describe('extractEmbeddedFileRefs', () => { it('collects de-duplicated keys and ids from a document via the shared parser', () => { const content = ` @@ -48,7 +39,6 @@ describe('extractEmbeddedFileRefs', () => { ![c](/workspace/W1/files/4bdaf6c4-072e-464e-891d-b6af3b5fe2cc) ![dup](/api/files/serve/s3/${ENCODED}) ![ext](https://cdn.example.com/x.png) - ![absolute](https://sim.ai/api/files/view/wf_external) ![pub](/api/files/serve/profile-pictures%2Fu1%2Favatar.png) ` const { keys, ids } = extractEmbeddedFileRefs(content) @@ -69,25 +59,3 @@ describe('extractEmbeddedFileRefs', () => { expect(k.length + d.length).toBe(50) }) }) - -describe('replaceEmbeddedFileRefs', () => { - it('rewrites key and id spellings without touching absolute URLs', () => { - const content = [ - `![key](/api/files/serve/${ENCODED}?context=workspace)`, - '![id](/api/files/view/wf_abc)', - '![absolute](https://sim.ai/api/files/view/wf_abc)', - ].join('\n') - const replacements = new Map([ - [`key:${KEY}`, './assets/key.png'], - ['id:wf_abc', './assets/id.png'], - ]) - - expect(replaceEmbeddedFileRefs(content, replacements)).toBe( - [ - '![key](./assets/key.png)', - '![id](./assets/id.png)', - '![absolute](https://sim.ai/api/files/view/wf_abc)', - ].join('\n') - ) - }) -}) diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts index c4dfea38ab8..7e780362ea5 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -10,24 +10,17 @@ /** 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 -export type ResolvedEmbeddedFileRef = Exclude /** 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. A required start/delimiter prevents matching the path portion of an absolute URL; the captured - * run stops at Markdown/HTML delimiters so authoritative parsing is left to - * {@link extractEmbeddedFileRef}. + * path. The captured run stops at whitespace/quote/paren/angle/query so authoritative parsing is left + * to {@link extractEmbeddedFileRef}. */ const EMBED_URL_RE = - /(^|[\s("'<>])((?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>]*)/gm - -/** Stable map key shared by routes and renderers for either supported reference spelling. */ -export function embeddedFileRefKey(ref: ResolvedEmbeddedFileRef): string { - return 'key' in ref ? `key:${ref.key}` : `id:${ref.fileId}` -} + /(?:\/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 @@ -72,7 +65,7 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: const keys = new Set() const ids = new Set() for (const match of content.matchAll(EMBED_URL_RE)) { - const ref = extractEmbeddedFileRef(match[2]) + const ref = extractEmbeddedFileRef(match[0]) if (!ref) continue if ('key' in ref) keys.add(ref.key) else ids.add(ref.fileId) @@ -80,15 +73,3 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: } return { keys: [...keys], ids: [...ids] } } - -/** Rewrite authorized embedded references while leaving external and unmatched URLs untouched. */ -export function replaceEmbeddedFileRefs( - content: string, - replacements: ReadonlyMap -): string { - return content.replace(EMBED_URL_RE, (_match, prefix: string, candidate: string) => { - const ref = extractEmbeddedFileRef(candidate) - const replacement = ref ? replacements.get(embeddedFileRefKey(ref)) : undefined - return `${prefix}${replacement ?? candidate}` - }) -} diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 4521dc6bcf4..79032282bcf 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -27,10 +27,9 @@ describe('isMarkdownFile', () => { expect(isMarkdownFile({ name: 'doc.markdown' })).toBe(true) }) - it('is true for Markdown MIME types even without a .md name', () => { + it('is true for a text/markdown MIME even without a .md name', () => { expect(isMarkdownFile({ type: 'text/markdown', name: 'notes' })).toBe(true) expect(isMarkdownFile({ type: 'text/markdown', name: 'doc.txt' })).toBe(true) - expect(isMarkdownFile({ type: 'text/x-markdown', name: 'legacy' })).toBe(true) }) it('is false for non-markdown files', () => { diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index d3c0c9fbb79..da2c51c0f91 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -213,12 +213,12 @@ export function getFileExtension(filename: string): string { /** * Whether a file renders in the collaborative rich markdown editor. Server-safe counterpart to the * client's `isMarkdownFile` (which uses `resolvePreviewType`): the editor treats a file as markdown by - * its Markdown MIME *or* a `.md`/`.markdown` extension — MIME first, matching the client — so a - * Markdown file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc merge) - * to exactly the files that can be open in that editor. + * its `text/markdown` MIME *or* a `.md`/`.markdown` extension — MIME first, matching the client — so a + * `text/markdown` file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc + * merge) to exactly the files that can be open in that editor. */ export function isMarkdownFile(file: { type?: string | null; name: string }): boolean { - if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true + if (file.type === 'text/markdown') return true const ext = getFileExtension(file.name) return ext === 'md' || ext === 'markdown' } From 2b17c81fa65d42dc07a9fea2087c49a3d5e7f243 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 09:33:30 -0700 Subject: [PATCH 10/18] refactor(files): keep PDF parsing local --- .../api/files/export/[id]/markdown-pdf.tsx | 22 +++++++++++++++++-- apps/sim/lib/collab-doc/server-markdown.ts | 22 ------------------- 2 files changed, 20 insertions(+), 24 deletions(-) delete mode 100644 apps/sim/lib/collab-doc/server-markdown.ts diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 283cd6dbd35..ccd22a9d512 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -15,12 +15,12 @@ import { } from '@react-pdf/renderer' import type { JSONContent } from '@tiptap/core' import sharp from 'sharp' -import { parseServerMarkdownToDoc } from '@/lib/collab-doc/server-markdown' import { type EmbeddedFileRef, extractEmbeddedFileRef, } from '@/lib/uploads/utils/embedded-image-ref' import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' type PdfImage = { data: Buffer; format: 'png' } type ResolvedPdfImageRef = Exclude @@ -41,6 +41,23 @@ interface PdfFont { const require = createRequire(import.meta.url) +/** + * Ensure a DOM exists for the TipTap Markdown parser used by this PDF-only server module. The + * renderer is loaded lazily by the PDF export branch, so this setup never affects ordinary exports. + * Re-check both globals on every call to avoid accepting the partial `document` exposed by Next. + */ +function ensureDomForMarkdownPdf(): void { + if (typeof window !== 'undefined' && typeof document !== 'undefined') return + const { JSDOM } = require('jsdom') as typeof import('jsdom') + const { window: jsdomWindow } = new JSDOM('') + // double-cast-allowed: assigning the jsdom shims onto the global needs an + // index-signature view of `globalThis`, whose declared type has none. + const globals = globalThis as unknown as Record + globals.window = jsdomWindow + globals.document = jsdomWindow.document + globals.navigator ??= jsdomWindow.navigator +} + function resolveBrandFont(filename: string): string { const candidates = [ join(process.cwd(), 'public', 'brand', 'fonts', filename), @@ -678,7 +695,8 @@ export async function renderMarkdownPdf({ }: MarkdownPdfInput): Promise { const normalizedImages = await normalizeImages(images) const { body } = splitFrontmatter(markdown) - const document = parseServerMarkdownToDoc(body) + ensureDomForMarkdownPdf() + const document = parseMarkdownToDoc(body) assertDocumentWithinLimits(document) return renderToBuffer( diff --git a/apps/sim/lib/collab-doc/server-markdown.ts b/apps/sim/lib/collab-doc/server-markdown.ts deleted file mode 100644 index 62f519d1331..00000000000 --- a/apps/sim/lib/collab-doc/server-markdown.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { JSONContent } from '@tiptap/core' -import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' - -/** - * Installs the minimal DOM globals needed by the canonical TipTap Markdown engine in a server - * process. The single jsdom window is reused for every parse/serialize call in that process. - */ -export function ensureServerMarkdownDom(): void { - if (typeof window !== 'undefined' && typeof document !== 'undefined') return - const { JSDOM } = require('jsdom') as typeof import('jsdom') - const { window: jsdomWindow } = new JSDOM('') - const globals = globalThis as unknown as Record - globals.window = jsdomWindow - globals.document = jsdomWindow.document - globals.navigator ??= jsdomWindow.navigator -} - -/** Parse Markdown with the exact extension set and schema used by the Files editor. */ -export function parseServerMarkdownToDoc(markdown: string): JSONContent { - ensureServerMarkdownDom() - return parseMarkdownToDoc(markdown) -} From afa6302d09e1f8678ff087fecf8e34c61b3e82de Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 10:44:20 -0700 Subject: [PATCH 11/18] chore: retrigger security checks --- apps/sim/app/api/files/export/[id]/markdown-pdf.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index ccd22a9d512..29f4dd95a5a 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -22,6 +22,7 @@ import { import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +// Temporary no-op marker to retrigger external security checks. type PdfImage = { data: Buffer; format: 'png' } type ResolvedPdfImageRef = Exclude From 2779c642541929f6b581355a60a309ef95b48ba7 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 10:44:31 -0700 Subject: [PATCH 12/18] chore: remove security check trigger --- apps/sim/app/api/files/export/[id]/markdown-pdf.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 29f4dd95a5a..ccd22a9d512 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -22,7 +22,6 @@ import { import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' -// Temporary no-op marker to retrigger external security checks. type PdfImage = { data: Buffer; format: 'png' } type ResolvedPdfImageRef = Exclude From 8e30176f4b1410146099abdf6c590c3f2d361d1a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 12:27:17 -0700 Subject: [PATCH 13/18] fix(files): preserve PDF image links and table alignment --- .../files/export/[id]/markdown-pdf.test.ts | 55 ++++++++++++++++ .../api/files/export/[id]/markdown-pdf.tsx | 66 +++++++++++++++---- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts index 3b6157af616..236ea5e51d3 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -150,6 +150,61 @@ ${repeatedParagraphs}` expect(text).toContain('Value') }) + it('preserves links on linked-image fallbacks', async () => { + const destination = 'https://sim.ai/docs' + const buffer = await renderMarkdownPdf({ + markdown: `[![Documentation badge](https://example.com/badge.svg)](${destination})`, + title: 'Linked image', + }) + + const document = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }) + .promise + try { + const annotations = await (await document.getPage(1)).getAnnotations() + expect(annotations.some((annotation) => annotation.url === destination)).toBe(true) + } finally { + await document.destroy() + } + }) + + it('preserves GFM table-cell alignment', async () => { + const buffer = await renderMarkdownPdf({ + markdown: `| LEFTVALUE | +| :--- | +| left | + +| CENTERVALUE | +| :---: | +| center | + +| RIGHTVALUE | +| ---: | +| right |`, + title: 'Aligned tables', + }) + + const document = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }) + .promise + try { + const content = await (await document.getPage(1)).getTextContent() + const textX = (value: string) => { + const item = content.items.find( + (candidate) => 'str' in candidate && candidate.str === value + ) + expect(item && 'transform' in item).toBe(true) + return item && 'transform' in item ? item.transform[4] : 0 + } + const leftX = textX('LEFTVALUE') + const centerX = textX('CENTERVALUE') + const rightX = textX('RIGHTVALUE') + + expect(centerX - leftX).toBeGreaterThan(100) + expect(rightX - centerX).toBeGreaterThan(100) + } finally { + await document.destroy() + } + }) + it('falls back instead of decoding an image above the pixel ceiling', async () => { const oversizedSvg = Buffer.from( '' diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index ccd22a9d512..f50ca816652 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -249,6 +249,8 @@ const styles = StyleSheet.create({ minWidth: 0, padding: 5, }, + tableCellCenter: { textAlign: 'center' }, + tableCellRight: { textAlign: 'right' }, tableHeader: { backgroundColor: '#f1f3f5', fontWeight: 700 }, imageBlock: { marginBottom: 11 }, image: { maxHeight: 430, objectFit: 'contain', width: '100%' }, @@ -451,10 +453,24 @@ function renderImage( key: string ): ReactNode { const src = stringAttr(node, 'src') ?? '' + const rawHref = stringAttr(node, 'href') + const href = rawHref ? safeLink(rawHref) : undefined const ref = extractEmbeddedFileRef(src) const image = ref ? images.get(markdownPdfImageKey(ref)) : undefined if (!image) { const alt = stringAttr(node, 'alt') + const fallback = ( + + {renderText(alt ? `Image: ${alt}` : 'Image unavailable', key)} + + ) + if (href) { + return ( + + {fallback} + + ) + } return ( {renderText(alt ? `Image: ${alt}` : 'Image unavailable', key)} @@ -466,6 +482,18 @@ function renderImage( const width = Number.isFinite(requestedWidth) ? Math.min(Math.max(requestedWidth, 1), PDF_TABLE_CONTENT_WIDTH) : undefined + const renderedImage = ( + + + + ) + if (href) { + return ( + + {renderedImage} + + ) + } return ( @@ -515,19 +543,31 @@ function renderList( function renderTableRow(row: JSONContent, key: string, header: boolean): ReactNode { return ( - {(row.content ?? []).map((cell, index) => ( - - {(cell.content ?? []).map((child, childIndex) => ( - - {childIndex > 0 ? '\n' : null} - {renderInline(`${key}-${index}-${childIndex}`, child.content)} - - ))} - - ))} + {(row.content ?? []).map((cell, index) => { + const alignmentStyle = + cell.attrs?.align === 'center' + ? styles.tableCellCenter + : cell.attrs?.align === 'right' + ? styles.tableCellRight + : undefined + return ( + + {(cell.content ?? []).map((child, childIndex) => ( + + {childIndex > 0 ? '\n' : null} + {renderInline(`${key}-${index}-${childIndex}`, child.content)} + + ))} + + ) + })} ) } From e9d37772995b2bbfc90d2b1e5f9d66984020a8d3 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 2 Oct 2026 21:10:24 -0700 Subject: [PATCH 14/18] fix(files): format PDF export test import --- apps/sim/app/api/files/export/[id]/route.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index 5aa52463d59..810f766cd27 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -6,10 +6,7 @@ import { filesAuthorizationMockFns, } from '@sim/testing/mocks/files-authorization.mock' import { hybridAuthMockFns } from '@sim/testing/mocks/hybrid-auth.mock' -import { - posthogServerMock, - posthogServerMockFns, -} from '@sim/testing/mocks/posthog-server.mock' +import { posthogServerMock, posthogServerMockFns } from '@sim/testing/mocks/posthog-server.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { uploadsMetadataMock, From 907df806553a93bbdaa815b9cc2648e89e4b439c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 2 Oct 2026 21:27:15 -0700 Subject: [PATCH 15/18] fix(files): align PDF export with repository audits --- .../api/files/export/[id]/markdown-pdf.tsx | 47 +++++-------------- .../app/api/files/export/[id]/route.test.ts | 8 ++-- .../sim/lib/api/contracts/storage-transfer.ts | 2 +- apps/sim/lib/uploads/client/download.ts | 4 +- 4 files changed, 19 insertions(+), 42 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index f50ca816652..f6623c154e6 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -68,47 +68,26 @@ function resolveBrandFont(filename: string): string { return font } -function resolveDependencyFont(packageName: string, filename: string): string { - const relativePath = join(packageName, 'files', filename) - const candidates = [ - join(process.cwd(), 'node_modules', relativePath), - join(process.cwd(), '..', '..', 'node_modules', relativePath), - join(process.cwd(), 'apps', 'sim', 'node_modules', relativePath), - ] - const font = candidates.find(existsSync) - if (!font) throw new Error(`PDF dependency font not found: ${filename}`) - return font -} - const GEIST_REGULAR = resolveBrandFont('Geist-Regular.ttf') const GEIST_MEDIUM = resolveBrandFont('Geist-Medium.ttf') -const UNIFONT_REGULAR = resolveDependencyFont( - '@fontsource/unifont', - 'unifont-latin-400-normal.woff' -) -const NOTO_ARABIC = resolveDependencyFont( - '@fontsource/noto-sans-arabic', - 'noto-sans-arabic-arabic-400-normal.woff' +const UNIFONT_REGULAR = require.resolve('@fontsource/unifont/files/unifont-latin-400-normal.woff') +const NOTO_ARABIC = require.resolve( + '@fontsource/noto-sans-arabic/files/noto-sans-arabic-arabic-400-normal.woff' ) -const NOTO_ARABIC_BOLD = resolveDependencyFont( - '@fontsource/noto-sans-arabic', - 'noto-sans-arabic-arabic-700-normal.woff' +const NOTO_ARABIC_BOLD = require.resolve( + '@fontsource/noto-sans-arabic/files/noto-sans-arabic-arabic-700-normal.woff' ) -const NOTO_DEVANAGARI = resolveDependencyFont( - '@fontsource/noto-sans-devanagari', - 'noto-sans-devanagari-devanagari-400-normal.woff' +const NOTO_DEVANAGARI = require.resolve( + '@fontsource/noto-sans-devanagari/files/noto-sans-devanagari-devanagari-400-normal.woff' ) -const NOTO_DEVANAGARI_BOLD = resolveDependencyFont( - '@fontsource/noto-sans-devanagari', - 'noto-sans-devanagari-devanagari-700-normal.woff' +const NOTO_DEVANAGARI_BOLD = require.resolve( + '@fontsource/noto-sans-devanagari/files/noto-sans-devanagari-devanagari-700-normal.woff' ) -const NOTO_HEBREW = resolveDependencyFont( - '@fontsource/noto-sans-hebrew', - 'noto-sans-hebrew-hebrew-400-normal.woff' +const NOTO_HEBREW = require.resolve( + '@fontsource/noto-sans-hebrew/files/noto-sans-hebrew-hebrew-400-normal.woff' ) -const NOTO_HEBREW_BOLD = resolveDependencyFont( - '@fontsource/noto-sans-hebrew', - 'noto-sans-hebrew-hebrew-700-normal.woff' +const NOTO_HEBREW_BOLD = require.resolve( + '@fontsource/noto-sans-hebrew/files/noto-sans-hebrew-hebrew-700-normal.woff' ) /** diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index 810f766cd27..76dbddcdd58 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -7,6 +7,7 @@ import { } from '@sim/testing/mocks/files-authorization.mock' import { hybridAuthMockFns } from '@sim/testing/mocks/hybrid-auth.mock' import { posthogServerMock, posthogServerMockFns } from '@sim/testing/mocks/posthog-server.mock' +import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { uploadsMetadataMock, @@ -20,13 +21,11 @@ import { getServeStoragePrefix } from '@/lib/uploads/config' const { mockExtractEmbeddedFileRefs, mockResolveWorkspaceInlineImage, - mockEnforceUserRateLimit, mockRenderMarkdownPdf, MockMarkdownPdfLimitError, } = vi.hoisted(() => ({ mockExtractEmbeddedFileRefs: vi.fn(), mockResolveWorkspaceInlineImage: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), mockRenderMarkdownPdf: vi.fn(), MockMarkdownPdfLimitError: class extends Error {}, })) @@ -45,9 +44,7 @@ vi.mock('@/lib/uploads/server/embedded-image-refs', () => ({ vi.mock('@/lib/uploads/server/inline-image', () => ({ resolveWorkspaceInlineImage: mockResolveWorkspaceInlineImage, })) -vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ - enforceUserRateLimit: mockEnforceUserRateLimit, -})) +vi.mock('@/lib/core/rate-limiter/route-helpers', () => rateLimiterMock) vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ MarkdownPdfLimitError: MockMarkdownPdfLimitError, markdownPdfImageKey: (ref: { key?: string; fileId?: string }) => @@ -65,6 +62,7 @@ const mockVerifyFileAccess = filesAuthorizationMockFns.mockVerifyFileAccess const mockCheckAuth = hybridAuthMockFns.mockCheckSessionOrInternalAuth const mockRecordAudit = auditMockFns.mockRecordAudit const mockCaptureServerEvent = posthogServerMockFns.mockCaptureServerEvent +const mockEnforceUserRateLimit = rateLimiterMockFns.mockEnforceUserRateLimit const MB = 1024 * 1024 const DOC_ID = 'doc-1' diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index c8ac63756fd..938395462be 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -200,7 +200,7 @@ export const fileExportParamsSchema = z.object({ id: workspaceFileIdSchema, }) -export const fileExportQuerySchema = z.object({ +const fileExportQuerySchema = z.object({ format: z.literal('pdf').optional(), }) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index ba0ad1d4644..926664a1cf8 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -1,6 +1,6 @@ import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' import { requestRaw } from '@/lib/api/client/request' -import { fileExportContract } from '@/lib/api/contracts/storage-transfer' +import { fileExportContract, type FileExportQuery } from '@/lib/api/contracts/storage-transfer' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { exportWorkspaceFileSnapshotContract } from '@/lib/api/contracts/workspace-files' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -43,7 +43,7 @@ function fileNameFromDisposition(response: Response, fallback: string): string { export async function triggerFileDownload( record: WorkspaceFileRecord, source?: FileDownloadSource | null, - options?: { format?: 'pdf' } + options?: FileExportQuery ): Promise { const isMarkdown = record.type === 'text/markdown' || From 8743bc27ce3b8e77941a961f7712e98d22be6bf3 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 2 Oct 2026 21:30:21 -0700 Subject: [PATCH 16/18] fix(files): preserve native PDF font path resolution --- apps/sim/app/api/files/export/[id]/markdown-pdf.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index f6623c154e6..4227ab4da56 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' +import * as nodeModule from 'node:module' import { join } from 'node:path' import type { ReactNode } from 'react' import { @@ -39,7 +39,8 @@ interface PdfFont { glyphs: GlyphFont } -const require = createRequire(import.meta.url) +/** A runtime anchor preserves Node filesystem paths instead of bundler module IDs. */ +const require = nodeModule.createRequire(join(process.cwd(), 'package.json')) /** * Ensure a DOM exists for the TipTap Markdown parser used by this PDF-only server module. The From f89abb3b5b2251425c6ffc0f5e3d43f9b1d2f39b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 2 Oct 2026 21:34:18 -0700 Subject: [PATCH 17/18] fix(files): sort PDF download contract imports --- apps/sim/lib/uploads/client/download.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index 926664a1cf8..dcc455e1958 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -1,6 +1,6 @@ import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' import { requestRaw } from '@/lib/api/client/request' -import { fileExportContract, type FileExportQuery } from '@/lib/api/contracts/storage-transfer' +import { type FileExportQuery, fileExportContract } from '@/lib/api/contracts/storage-transfer' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { exportWorkspaceFileSnapshotContract } from '@/lib/api/contracts/workspace-files' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' From 257de0afcfd5b99e376ffb0189f08125026d4df0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 2 Oct 2026 21:43:38 -0700 Subject: [PATCH 18/18] fix(files): include the PDF renderer Geist fonts Vendor the missing unmodified Geist Regular and Medium static TTF assets from vercel/geist-font v1.7.2 (a73329da8fc62afc917f796555202e4997f79b7c), with their SIL Open Font License. Existing public assets are copied into the standalone Docker image. --- apps/sim/public/brand/fonts/Geist-Medium.ttf | Bin 0 -> 127660 bytes apps/sim/public/brand/fonts/Geist-OFL.txt | 93 ++++++++++++++++++ apps/sim/public/brand/fonts/Geist-Regular.ttf | Bin 0 -> 126048 bytes 3 files changed, 93 insertions(+) create mode 100644 apps/sim/public/brand/fonts/Geist-Medium.ttf create mode 100644 apps/sim/public/brand/fonts/Geist-OFL.txt create mode 100644 apps/sim/public/brand/fonts/Geist-Regular.ttf diff --git a/apps/sim/public/brand/fonts/Geist-Medium.ttf b/apps/sim/public/brand/fonts/Geist-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..492b319a5ecd257d01264061522224a144564b0c GIT binary patch literal 127660 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