Skip to content

Commit 8a27014

Browse files
committed
fix(pdf): PDF previews by adding the missing preview endpoint and allowing same-origin blob URLs in iframe CSP
1 parent 47519e3 commit 8a27014

File tree

4 files changed

+154
-0
lines changed

4 files changed

+154
-0
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockVerifyWorkspaceMembership, mockGeneratePdfFromCode } = vi.hoisted(
8+
() => ({
9+
mockGetSession: vi.fn(),
10+
mockVerifyWorkspaceMembership: vi.fn(),
11+
mockGeneratePdfFromCode: vi.fn(),
12+
})
13+
)
14+
15+
vi.mock('@/lib/auth', () => ({
16+
getSession: mockGetSession,
17+
}))
18+
19+
vi.mock('@/app/api/workflows/utils', () => ({
20+
verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
21+
}))
22+
23+
vi.mock('@/lib/execution/doc-vm', () => ({
24+
generatePdfFromCode: mockGeneratePdfFromCode,
25+
}))
26+
27+
import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route'
28+
29+
describe('PDF preview API route', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks()
32+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
33+
mockVerifyWorkspaceMembership.mockResolvedValue(true)
34+
mockGeneratePdfFromCode.mockResolvedValue(Buffer.from('%PDF-test'))
35+
})
36+
37+
it('returns a generated PDF for authorized workspace members', async () => {
38+
const request = new NextRequest(
39+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
40+
{
41+
method: 'POST',
42+
headers: {
43+
'Content-Type': 'application/json',
44+
},
45+
body: JSON.stringify({ code: 'return 1' }),
46+
}
47+
)
48+
49+
const response = await POST(request, {
50+
params: Promise.resolve({ id: 'workspace-1' }),
51+
})
52+
53+
expect(response.status).toBe(200)
54+
expect(response.headers.get('Content-Type')).toBe('application/pdf')
55+
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
56+
expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1')
57+
expect(mockGeneratePdfFromCode).toHaveBeenCalledWith('return 1', 'workspace-1', request.signal)
58+
expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('%PDF-test')
59+
})
60+
61+
it('rejects requests without code', async () => {
62+
const request = new NextRequest(
63+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
64+
{
65+
method: 'POST',
66+
headers: {
67+
'Content-Type': 'application/json',
68+
},
69+
body: JSON.stringify({}),
70+
}
71+
)
72+
73+
const response = await POST(request, {
74+
params: Promise.resolve({ id: 'workspace-1' }),
75+
})
76+
77+
expect(response.status).toBe(400)
78+
await expect(response.json()).resolves.toEqual({ error: 'code is required' })
79+
expect(mockGeneratePdfFromCode).not.toHaveBeenCalled()
80+
})
81+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { generatePdfFromCode } from '@/lib/execution/doc-vm'
5+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
6+
7+
export const dynamic = 'force-dynamic'
8+
export const runtime = 'nodejs'
9+
10+
const logger = createLogger('PdfPreviewAPI')
11+
12+
/**
13+
* POST /api/workspaces/[id]/pdf/preview
14+
* Compile PDF-Lib source code and return the binary PDF for streaming preview.
15+
*/
16+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
17+
const { id: workspaceId } = await params
18+
19+
try {
20+
const session = await getSession()
21+
if (!session?.user?.id) {
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
26+
if (!membership) {
27+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
28+
}
29+
30+
let body: unknown
31+
try {
32+
body = await req.json()
33+
} catch {
34+
return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 })
35+
}
36+
const { code } = body as { code?: string }
37+
38+
if (typeof code !== 'string' || code.trim().length === 0) {
39+
return NextResponse.json({ error: 'code is required' }, { status: 400 })
40+
}
41+
42+
const MAX_CODE_BYTES = 512 * 1024
43+
if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) {
44+
return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 })
45+
}
46+
47+
const buffer = await generatePdfFromCode(code, workspaceId, req.signal)
48+
49+
return new NextResponse(new Uint8Array(buffer), {
50+
status: 200,
51+
headers: {
52+
'Content-Type': 'application/pdf',
53+
'Content-Length': String(buffer.length),
54+
'Cache-Control': 'private, no-store',
55+
},
56+
})
57+
} catch (err) {
58+
const message = err instanceof Error ? err.message : 'PDF generation failed'
59+
logger.error('PDF preview generation failed', { error: message, workspaceId })
60+
return NextResponse.json({ error: message }, { status: 500 })
61+
}
62+
}

apps/sim/lib/core/security/csp.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ describe('generateRuntimeCSP', () => {
175175
expect(csp).not.toMatch(/\s{3,}/)
176176
expect(csp.trim()).toBe(csp)
177177
})
178+
179+
it('should allow blob URLs for iframe-based PDF previews', () => {
180+
const csp = generateRuntimeCSP()
181+
const frameSrcDirective = csp
182+
.split('; ')
183+
.find((directive) => directive.startsWith('frame-src '))
184+
185+
expect(frameSrcDirective).toBeDefined()
186+
expect(frameSrcDirective).toContain('blob:')
187+
})
178188
})
179189

180190
describe('addCSPSource', () => {

apps/sim/lib/core/security/csp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ const STATIC_CONNECT_SRC = [
111111

112112
const STATIC_FRAME_SRC = [
113113
"'self'",
114+
'blob:',
114115
'https://challenges.cloudflare.com',
115116
'https://drive.google.com',
116117
'https://docs.google.com',

0 commit comments

Comments
 (0)