Skip to content

Commit 524f33c

Browse files
fix(pdf): PDF previews by adding the missing preview endpoint and allowing same-origin blob URLs in iframe CSP (#4225)
* fix(pdf): PDF previews by adding the missing preview endpoint and allowing same-origin blob URLs in iframe CSP * fixed * add preview routes and tests * follow nextjs route gen strat
1 parent 47519e3 commit 524f33c

File tree

10 files changed

+734
-58
lines changed

10 files changed

+734
-58
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { toError } from '@/lib/core/utils/helpers'
5+
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
6+
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
7+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
8+
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
9+
10+
/**
11+
* Config for a document preview route handler.
12+
*
13+
* All three document preview endpoints (PDF / PPTX / DOCX) share the same
14+
* shape: auth → workspace membership → JSON body parse → `code` validation →
15+
* size guard → `runSandboxTask(taskId, ...)` → binary response. The only
16+
* differences between them are the sandbox task, the response MIME type, and
17+
* the logger/label used for the 500 path.
18+
*/
19+
export interface DocumentPreviewRouteConfig {
20+
/** Sandbox task registered in `apps/sim/sandbox-tasks/registry.ts`. */
21+
taskId: SandboxTaskId
22+
/** Content-Type of the binary returned on success. */
23+
contentType: string
24+
/** Short label used for the logger name + 500 log message. */
25+
label: 'PDF' | 'PPTX' | 'DOCX'
26+
}
27+
28+
/**
29+
* Build a Next.js POST handler for one of the document preview endpoints.
30+
*
31+
* Everything security-relevant (session, workspace membership, JSON shape,
32+
* empty/oversized code) is enforced before we ever reach the isolated-vm
33+
* sandbox, and `runSandboxTask` is always invoked with the session owner key
34+
* + `req.signal` so pool fairness and client-disconnect cancellation behave
35+
* identically across all three formats.
36+
*/
37+
export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) {
38+
const logger = createLogger(`${config.label}PreviewAPI`)
39+
40+
return async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
41+
const { id: workspaceId } = await params
42+
43+
try {
44+
const session = await getSession()
45+
if (!session?.user?.id) {
46+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
47+
}
48+
49+
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
50+
if (!membership) {
51+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
52+
}
53+
54+
let body: unknown
55+
try {
56+
body = await req.json()
57+
} catch {
58+
return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 })
59+
}
60+
const { code } = body as { code?: string }
61+
62+
if (typeof code !== 'string' || code.trim().length === 0) {
63+
return NextResponse.json({ error: 'code is required' }, { status: 400 })
64+
}
65+
66+
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
67+
return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 })
68+
}
69+
70+
const buffer = await runSandboxTask(
71+
config.taskId,
72+
{ code, workspaceId },
73+
{ ownerKey: `user:${session.user.id}`, signal: req.signal }
74+
)
75+
76+
return new NextResponse(new Uint8Array(buffer), {
77+
status: 200,
78+
headers: {
79+
'Content-Type': config.contentType,
80+
'Content-Length': String(buffer.length),
81+
'Cache-Control': 'private, no-store',
82+
},
83+
})
84+
} catch (err) {
85+
const message = toError(err).message
86+
logger.error(`${config.label} preview generation failed`, { error: message, workspaceId })
87+
return NextResponse.json({ error: message }, { status: 500 })
88+
}
89+
}
90+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
7+
8+
const { mockGetSession, mockVerifyWorkspaceMembership, mockRunSandboxTask } = vi.hoisted(() => ({
9+
mockGetSession: vi.fn(),
10+
mockVerifyWorkspaceMembership: vi.fn(),
11+
mockRunSandboxTask: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({
15+
getSession: mockGetSession,
16+
}))
17+
18+
vi.mock('@/app/api/workflows/utils', () => ({
19+
verifyWorkspaceMembership: mockVerifyWorkspaceMembership,
20+
}))
21+
22+
vi.mock('@/lib/execution/sandbox/run-task', () => ({
23+
runSandboxTask: mockRunSandboxTask,
24+
}))
25+
26+
import { POST } from '@/app/api/workspaces/[id]/docx/preview/route'
27+
28+
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
29+
30+
describe('DOCX preview API route', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
34+
mockVerifyWorkspaceMembership.mockResolvedValue(true)
35+
mockRunSandboxTask.mockResolvedValue(Buffer.from('PK\x03\x04docx'))
36+
})
37+
38+
it('returns a generated DOCX for authorized workspace members', async () => {
39+
const request = new NextRequest(
40+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
41+
{
42+
method: 'POST',
43+
headers: {
44+
'Content-Type': 'application/json',
45+
},
46+
body: JSON.stringify({ code: 'return 1' }),
47+
}
48+
)
49+
50+
const response = await POST(request, {
51+
params: Promise.resolve({ id: 'workspace-1' }),
52+
})
53+
54+
expect(response.status).toBe(200)
55+
expect(response.headers.get('Content-Type')).toBe(DOCX_MIME)
56+
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
57+
expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1')
58+
expect(mockRunSandboxTask).toHaveBeenCalledWith(
59+
'docx-generate',
60+
{ code: 'return 1', workspaceId: 'workspace-1' },
61+
{ ownerKey: 'user:user-1', signal: request.signal }
62+
)
63+
expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('PK\x03\x04docx')
64+
})
65+
66+
it('rejects requests without code', async () => {
67+
const request = new NextRequest(
68+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
69+
{
70+
method: 'POST',
71+
headers: {
72+
'Content-Type': 'application/json',
73+
},
74+
body: JSON.stringify({}),
75+
}
76+
)
77+
78+
const response = await POST(request, {
79+
params: Promise.resolve({ id: 'workspace-1' }),
80+
})
81+
82+
expect(response.status).toBe(400)
83+
await expect(response.json()).resolves.toEqual({ error: 'code is required' })
84+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
85+
})
86+
87+
it('rejects oversized preview source payloads', async () => {
88+
const request = new NextRequest(
89+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
90+
{
91+
method: 'POST',
92+
headers: {
93+
'Content-Type': 'application/json',
94+
},
95+
body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }),
96+
}
97+
)
98+
99+
const response = await POST(request, {
100+
params: Promise.resolve({ id: 'workspace-1' }),
101+
})
102+
103+
expect(response.status).toBe(413)
104+
await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' })
105+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
106+
})
107+
108+
it('returns 401 for unauthenticated requests', async () => {
109+
mockGetSession.mockResolvedValue(null)
110+
111+
const request = new NextRequest(
112+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
113+
{
114+
method: 'POST',
115+
headers: {
116+
'Content-Type': 'application/json',
117+
},
118+
body: JSON.stringify({ code: 'return 1' }),
119+
}
120+
)
121+
122+
const response = await POST(request, {
123+
params: Promise.resolve({ id: 'workspace-1' }),
124+
})
125+
126+
expect(response.status).toBe(401)
127+
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
128+
expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled()
129+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
130+
})
131+
132+
it('returns 403 when the user is not a workspace member', async () => {
133+
mockVerifyWorkspaceMembership.mockResolvedValue(false)
134+
135+
const request = new NextRequest(
136+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
137+
{
138+
method: 'POST',
139+
headers: {
140+
'Content-Type': 'application/json',
141+
},
142+
body: JSON.stringify({ code: 'return 1' }),
143+
}
144+
)
145+
146+
const response = await POST(request, {
147+
params: Promise.resolve({ id: 'workspace-1' }),
148+
})
149+
150+
expect(response.status).toBe(403)
151+
await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' })
152+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
153+
})
154+
155+
it('returns 400 for requests with invalid JSON bodies', async () => {
156+
const request = new NextRequest(
157+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
158+
{
159+
method: 'POST',
160+
headers: {
161+
'Content-Type': 'application/json',
162+
},
163+
body: '{ not valid json',
164+
}
165+
)
166+
167+
const response = await POST(request, {
168+
params: Promise.resolve({ id: 'workspace-1' }),
169+
})
170+
171+
expect(response.status).toBe(400)
172+
await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' })
173+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
174+
})
175+
176+
it('returns 500 when DOCX generation throws', async () => {
177+
mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed'))
178+
179+
const request = new NextRequest(
180+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
181+
{
182+
method: 'POST',
183+
headers: {
184+
'Content-Type': 'application/json',
185+
},
186+
body: JSON.stringify({ code: 'return 1' }),
187+
}
188+
)
189+
190+
const response = await POST(request, {
191+
params: Promise.resolve({ id: 'workspace-1' }),
192+
})
193+
194+
expect(response.status).toBe(500)
195+
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
196+
})
197+
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route'
2+
3+
export const dynamic = 'force-dynamic'
4+
export const runtime = 'nodejs'
5+
6+
/**
7+
* POST /api/workspaces/[id]/docx/preview
8+
* Compile docx source code and return the binary DOCX for streaming preview.
9+
*/
10+
export const POST = createDocumentPreviewRoute({
11+
taskId: 'docx-generate',
12+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
13+
label: 'DOCX',
14+
})

0 commit comments

Comments
 (0)