diff --git a/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts new file mode 100644 index 0000000000..495dd9ead3 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts @@ -0,0 +1,90 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { toError } from '@/lib/core/utils/helpers' +import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' +import { runSandboxTask } from '@/lib/execution/sandbox/run-task' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import type { SandboxTaskId } from '@/sandbox-tasks/registry' + +/** + * Config for a document preview route handler. + * + * All three document preview endpoints (PDF / PPTX / DOCX) share the same + * shape: auth → workspace membership → JSON body parse → `code` validation → + * size guard → `runSandboxTask(taskId, ...)` → binary response. The only + * differences between them are the sandbox task, the response MIME type, and + * the logger/label used for the 500 path. + */ +export interface DocumentPreviewRouteConfig { + /** Sandbox task registered in `apps/sim/sandbox-tasks/registry.ts`. */ + taskId: SandboxTaskId + /** Content-Type of the binary returned on success. */ + contentType: string + /** Short label used for the logger name + 500 log message. */ + label: 'PDF' | 'PPTX' | 'DOCX' +} + +/** + * Build a Next.js POST handler for one of the document preview endpoints. + * + * Everything security-relevant (session, workspace membership, JSON shape, + * empty/oversized code) is enforced before we ever reach the isolated-vm + * sandbox, and `runSandboxTask` is always invoked with the session owner key + * + `req.signal` so pool fairness and client-disconnect cancellation behave + * identically across all three formats. + */ +export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { + const logger = createLogger(`${config.label}PreviewAPI`) + + return async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!membership) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 }) + } + const { code } = body as { code?: string } + + if (typeof code !== 'string' || code.trim().length === 0) { + return NextResponse.json({ error: 'code is required' }, { status: 400 }) + } + + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) + } + + const buffer = await runSandboxTask( + config.taskId, + { code, workspaceId }, + { ownerKey: `user:${session.user.id}`, signal: req.signal } + ) + + return new NextResponse(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': config.contentType, + 'Content-Length': String(buffer.length), + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + const message = toError(err).message + logger.error(`${config.label} preview generation failed`, { error: message, workspaceId }) + return NextResponse.json({ error: message }, { status: 500 }) + } + } +} diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts new file mode 100644 index 0000000000..1707595868 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' + +const { mockGetSession, mockVerifyWorkspaceMembership, mockRunSandboxTask } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), + mockRunSandboxTask: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +})) + +vi.mock('@/lib/execution/sandbox/run-task', () => ({ + runSandboxTask: mockRunSandboxTask, +})) + +import { POST } from '@/app/api/workspaces/[id]/docx/preview/route' + +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + +describe('DOCX preview API route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockVerifyWorkspaceMembership.mockResolvedValue(true) + mockRunSandboxTask.mockResolvedValue(Buffer.from('PK\x03\x04docx')) + }) + + it('returns a generated DOCX for authorized workspace members', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe(DOCX_MIME) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1') + expect(mockRunSandboxTask).toHaveBeenCalledWith( + 'docx-generate', + { code: 'return 1', workspaceId: 'workspace-1' }, + { ownerKey: 'user:user-1', signal: request.signal } + ) + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('PK\x03\x04docx') + }) + + it('rejects requests without code', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'code is required' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('rejects oversized preview source payloads', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 401 for unauthenticated requests', async () => { + mockGetSession.mockResolvedValue(null) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled() + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 403 when the user is not a workspace member', async () => { + mockVerifyWorkspaceMembership.mockResolvedValue(false) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 400 for requests with invalid JSON bodies', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: '{ not valid json', + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 500 when DOCX generation throws', async () => { + mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed')) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts new file mode 100644 index 0000000000..de5c158758 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts @@ -0,0 +1,14 @@ +import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +/** + * POST /api/workspaces/[id]/docx/preview + * Compile docx source code and return the binary DOCX for streaming preview. + */ +export const POST = createDocumentPreviewRoute({ + taskId: 'docx-generate', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + label: 'DOCX', +}) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts new file mode 100644 index 0000000000..e9592f3ed7 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' + +const { mockGetSession, mockVerifyWorkspaceMembership, mockRunSandboxTask } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), + mockRunSandboxTask: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +})) + +vi.mock('@/lib/execution/sandbox/run-task', () => ({ + runSandboxTask: mockRunSandboxTask, +})) + +import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route' + +describe('PDF preview API route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockVerifyWorkspaceMembership.mockResolvedValue(true) + mockRunSandboxTask.mockResolvedValue(Buffer.from('%PDF-test')) + }) + + it('returns a generated PDF for authorized workspace members', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1') + expect(mockRunSandboxTask).toHaveBeenCalledWith( + 'pdf-generate', + { code: 'return 1', workspaceId: 'workspace-1' }, + { ownerKey: 'user:user-1', signal: request.signal } + ) + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('%PDF-test') + }) + + it('rejects requests without code', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'code is required' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('rejects oversized preview source payloads', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 401 for unauthenticated requests', async () => { + mockGetSession.mockResolvedValue(null) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled() + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 403 when the user is not a workspace member', async () => { + mockVerifyWorkspaceMembership.mockResolvedValue(false) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 400 for requests with invalid JSON bodies', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: '{ not valid json', + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 500 when PDF generation throws', async () => { + mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed')) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts new file mode 100644 index 0000000000..faaba5b8c4 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts @@ -0,0 +1,14 @@ +import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +/** + * POST /api/workspaces/[id]/pdf/preview + * Compile PDF-Lib source code and return the binary PDF for streaming preview. + */ +export const POST = createDocumentPreviewRoute({ + taskId: 'pdf-generate', + contentType: 'application/pdf', + label: 'PDF', +}) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts new file mode 100644 index 0000000000..776ba4120e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' + +const { mockGetSession, mockVerifyWorkspaceMembership, mockRunSandboxTask } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), + mockRunSandboxTask: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +})) + +vi.mock('@/lib/execution/sandbox/run-task', () => ({ + runSandboxTask: mockRunSandboxTask, +})) + +import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route' + +const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + +describe('PPTX preview API route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockVerifyWorkspaceMembership.mockResolvedValue(true) + mockRunSandboxTask.mockResolvedValue(Buffer.from('PK\x03\x04pptx')) + }) + + it('returns a generated PPTX for authorized workspace members', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe(PPTX_MIME) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1') + expect(mockRunSandboxTask).toHaveBeenCalledWith( + 'pptx-generate', + { code: 'return 1', workspaceId: 'workspace-1' }, + { ownerKey: 'user:user-1', signal: request.signal } + ) + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('PK\x03\x04pptx') + }) + + it('rejects requests without code', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'code is required' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('rejects oversized preview source payloads', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 401 for unauthenticated requests', async () => { + mockGetSession.mockResolvedValue(null) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled() + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 403 when the user is not a workspace member', async () => { + mockVerifyWorkspaceMembership.mockResolvedValue(false) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 400 for requests with invalid JSON bodies', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: '{ not valid json', + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' }) + expect(mockRunSandboxTask).not.toHaveBeenCalled() + }) + + it('returns 500 when PPTX generation throws', async () => { + mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed')) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'return 1' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts index 71e56408b7..e189091f12 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -1,66 +1,14 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { runSandboxTask } from '@/lib/execution/sandbox/run-task' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('PptxPreviewAPI') - /** * POST /api/workspaces/[id]/pptx/preview * Compile PptxGenJS source code and return the binary PPTX for streaming preview. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 }) - } - const { code } = body as { code?: string } - - if (typeof code !== 'string' || code.trim().length === 0) { - return NextResponse.json({ error: 'code is required' }, { status: 400 }) - } - - const MAX_CODE_BYTES = 512 * 1024 - if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) { - return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) - } - - const buffer = await runSandboxTask( - 'pptx-generate', - { code, workspaceId }, - { ownerKey: `user:${session.user.id}`, signal: req.signal } - ) - - return new NextResponse(new Uint8Array(buffer), { - status: 200, - headers: { - 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'Content-Length': String(buffer.length), - 'Cache-Control': 'private, no-store', - }, - }) - } catch (err) { - const message = err instanceof Error ? err.message : 'PPTX generation failed' - logger.error('PPTX preview generation failed', { error: message, workspaceId }) - return NextResponse.json({ error: message }, { status: 500 }) - } -} +export const POST = createDocumentPreviewRoute({ + taskId: 'pptx-generate', + contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + label: 'PPTX', +}) diff --git a/apps/sim/lib/core/security/csp.test.ts b/apps/sim/lib/core/security/csp.test.ts index 2a6fb3da95..ad1b4a9ad8 100644 --- a/apps/sim/lib/core/security/csp.test.ts +++ b/apps/sim/lib/core/security/csp.test.ts @@ -175,6 +175,16 @@ describe('generateRuntimeCSP', () => { expect(csp).not.toMatch(/\s{3,}/) expect(csp.trim()).toBe(csp) }) + + it('should allow blob URLs for iframe-based PDF previews', () => { + const csp = generateRuntimeCSP() + const frameSrcDirective = csp + .split('; ') + .find((directive) => directive.startsWith('frame-src ')) + + expect(frameSrcDirective).toBeDefined() + expect(frameSrcDirective).toContain('blob:') + }) }) describe('addCSPSource', () => { diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 073a0c35f9..dbe039a0cf 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -111,6 +111,7 @@ const STATIC_CONNECT_SRC = [ const STATIC_FRAME_SRC = [ "'self'", + 'blob:', 'https://challenges.cloudflare.com', 'https://drive.google.com', 'https://docs.google.com', diff --git a/apps/sim/lib/execution/constants.ts b/apps/sim/lib/execution/constants.ts index faca9679b4..2950f2b71b 100644 --- a/apps/sim/lib/execution/constants.ts +++ b/apps/sim/lib/execution/constants.ts @@ -1,3 +1,13 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' export { DEFAULT_EXECUTION_TIMEOUT_MS } + +/** + * Maximum inline source size accepted by document preview endpoints. + * + * This is intentionally much lower than Next.js's default 10MB proxy body cap: + * preview requests send user-authored source code, not binary uploads. Keeping + * the limit at 1MB gives generous headroom for real PPTX/PDF generator scripts + * while reducing memory pressure and abuse potential from oversized payloads. + */ +export const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1 * 1024 * 1024