From 8a270143a929d4c5a4996540a5d5be2bd6187552 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 20:32:37 -0700 Subject: [PATCH 1/4] fix(pdf): PDF previews by adding the missing preview endpoint and allowing same-origin blob URLs in iframe CSP --- .../workspaces/[id]/pdf/preview/route.test.ts | 81 +++++++++++++++++++ .../api/workspaces/[id]/pdf/preview/route.ts | 62 ++++++++++++++ apps/sim/lib/core/security/csp.test.ts | 10 +++ apps/sim/lib/core/security/csp.ts | 1 + 4 files changed, 154 insertions(+) create mode 100644 apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts 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 00000000000..65b262354ea --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockVerifyWorkspaceMembership, mockGeneratePdfFromCode } = vi.hoisted( + () => ({ + mockGetSession: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), + mockGeneratePdfFromCode: vi.fn(), + }) +) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +})) + +vi.mock('@/lib/execution/doc-vm', () => ({ + generatePdfFromCode: mockGeneratePdfFromCode, +})) + +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) + mockGeneratePdfFromCode.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(mockGeneratePdfFromCode).toHaveBeenCalledWith('return 1', 'workspace-1', 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(mockGeneratePdfFromCode).not.toHaveBeenCalled() + }) +}) 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 00000000000..559546b514e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts @@ -0,0 +1,62 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { generatePdfFromCode } from '@/lib/execution/doc-vm' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('PdfPreviewAPI') + +/** + * POST /api/workspaces/[id]/pdf/preview + * Compile PDF-Lib source code and return the binary PDF 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 generatePdfFromCode(code, workspaceId, req.signal) + + return new NextResponse(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Length': String(buffer.length), + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'PDF generation failed' + logger.error('PDF preview generation failed', { error: message, workspaceId }) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/sim/lib/core/security/csp.test.ts b/apps/sim/lib/core/security/csp.test.ts index 2a6fb3da95d..ad1b4a9ad8b 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 073a0c35f97..dbe039a0cf7 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', From 6f693c1e2994a079245aa930df7bc10bff0f00e1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 20:36:00 -0700 Subject: [PATCH 2/4] fixed --- .../workspaces/[id]/pdf/preview/route.test.ts | 26 ++++++++++--------- .../api/workspaces/[id]/pdf/preview/route.ts | 8 ++++-- 2 files changed, 20 insertions(+), 14 deletions(-) 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 index 65b262354ea..f58b9dd11d3 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts @@ -4,13 +4,11 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockVerifyWorkspaceMembership, mockGeneratePdfFromCode } = vi.hoisted( - () => ({ - mockGetSession: vi.fn(), - mockVerifyWorkspaceMembership: vi.fn(), - mockGeneratePdfFromCode: vi.fn(), - }) -) +const { mockGetSession, mockVerifyWorkspaceMembership, mockRunSandboxTask } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), + mockRunSandboxTask: vi.fn(), +})) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, @@ -20,8 +18,8 @@ vi.mock('@/app/api/workflows/utils', () => ({ verifyWorkspaceMembership: mockVerifyWorkspaceMembership, })) -vi.mock('@/lib/execution/doc-vm', () => ({ - generatePdfFromCode: mockGeneratePdfFromCode, +vi.mock('@/lib/execution/sandbox/run-task', () => ({ + runSandboxTask: mockRunSandboxTask, })) import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route' @@ -31,7 +29,7 @@ describe('PDF preview API route', () => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockVerifyWorkspaceMembership.mockResolvedValue(true) - mockGeneratePdfFromCode.mockResolvedValue(Buffer.from('%PDF-test')) + mockRunSandboxTask.mockResolvedValue(Buffer.from('%PDF-test')) }) it('returns a generated PDF for authorized workspace members', async () => { @@ -54,7 +52,11 @@ describe('PDF preview API route', () => { 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(mockGeneratePdfFromCode).toHaveBeenCalledWith('return 1', 'workspace-1', request.signal) + 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') }) @@ -76,6 +78,6 @@ describe('PDF preview API route', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toEqual({ error: 'code is required' }) - expect(mockGeneratePdfFromCode).not.toHaveBeenCalled() + expect(mockRunSandboxTask).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts index 559546b514e..d3576f9eadd 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { generatePdfFromCode } from '@/lib/execution/doc-vm' +import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -44,7 +44,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) } - const buffer = await generatePdfFromCode(code, workspaceId, req.signal) + const buffer = await runSandboxTask( + 'pdf-generate', + { code, workspaceId }, + { ownerKey: `user:${session.user.id}`, signal: req.signal } + ) return new NextResponse(new Uint8Array(buffer), { status: 200, From 6a6c9c38bce16d46526d49f648b665da68176ad0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 20:50:22 -0700 Subject: [PATCH 3/4] add preview routes and tests --- .../[id]/docx/preview/route.test.ts | 197 ++++++++++++++++++ .../api/workspaces/[id]/docx/preview/route.ts | 67 ++++++ .../workspaces/[id]/pdf/preview/route.test.ts | 112 ++++++++++ .../api/workspaces/[id]/pdf/preview/route.ts | 7 +- .../[id]/pptx/preview/route.test.ts | 197 ++++++++++++++++++ .../api/workspaces/[id]/pptx/preview/route.ts | 7 +- apps/sim/lib/execution/constants.ts | 10 + 7 files changed, 591 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/docx/preview/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts 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 00000000000..17075958685 --- /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 00000000000..86a98362ccc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts @@ -0,0 +1,67 @@ +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' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('DocxPreviewAPI') + +/** + * POST /api/workspaces/[id]/docx/preview + * Compile docx source code and return the binary DOCX 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 }) + } + + 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( + 'docx-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.wordprocessingml.document', + 'Content-Length': String(buffer.length), + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + const message = toError(err).message + logger.error('DOCX preview generation failed', { error: message, workspaceId }) + return NextResponse.json({ error: message }, { status: 500 }) + } +} 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 index f58b9dd11d3..e9592f3ed76 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts @@ -3,6 +3,7 @@ */ 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(), @@ -80,4 +81,115 @@ describe('PDF preview API route', () => { 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 index d3576f9eadd..085e5c831ac 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts @@ -1,6 +1,8 @@ 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' @@ -39,8 +41,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'code is required' }, { status: 400 }) } - const MAX_CODE_BYTES = 512 * 1024 - if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) { + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) } @@ -59,7 +60,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: }, }) } catch (err) { - const message = err instanceof Error ? err.message : 'PDF generation failed' + const message = toError(err).message logger.error('PDF preview generation failed', { error: message, workspaceId }) return NextResponse.json({ error: message }, { status: 500 }) } 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 00000000000..776ba4120e4 --- /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 71e56408b7a..27f852a1ff6 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -1,6 +1,8 @@ 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' @@ -39,8 +41,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'code is required' }, { status: 400 }) } - const MAX_CODE_BYTES = 512 * 1024 - if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) { + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) } @@ -59,7 +60,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: }, }) } catch (err) { - const message = err instanceof Error ? err.message : 'PPTX generation failed' + const message = toError(err).message logger.error('PPTX preview generation failed', { error: message, workspaceId }) return NextResponse.json({ error: message }, { status: 500 }) } diff --git a/apps/sim/lib/execution/constants.ts b/apps/sim/lib/execution/constants.ts index faca9679b4b..2950f2b71bf 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 From ef2b8b2791071854de38a10fcd13a7eb42e3f670 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 20:52:18 -0700 Subject: [PATCH 4/4] follow nextjs route gen strat --- .../[id]/_preview/create-preview-route.ts | 90 +++++++++++++++++++ .../api/workspaces/[id]/docx/preview/route.ts | 65 ++------------ .../api/workspaces/[id]/pdf/preview/route.ts | 65 ++------------ .../api/workspaces/[id]/pptx/preview/route.ts | 65 ++------------ 4 files changed, 108 insertions(+), 177 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts 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 00000000000..495dd9ead38 --- /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.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts index 86a98362ccc..de5c1587583 100644 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts @@ -1,67 +1,14 @@ -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 { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('DocxPreviewAPI') - /** * POST /api/workspaces/[id]/docx/preview * Compile docx source code and return the binary DOCX 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 }) - } - - 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( - 'docx-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.wordprocessingml.document', - 'Content-Length': String(buffer.length), - 'Cache-Control': 'private, no-store', - }, - }) - } catch (err) { - const message = toError(err).message - logger.error('DOCX preview generation failed', { error: message, workspaceId }) - return NextResponse.json({ error: message }, { status: 500 }) - } -} +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.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts index 085e5c831ac..faaba5b8c42 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts @@ -1,67 +1,14 @@ -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 { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('PdfPreviewAPI') - /** * POST /api/workspaces/[id]/pdf/preview * Compile PDF-Lib source code and return the binary PDF 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 }) - } - - 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( - 'pdf-generate', - { code, workspaceId }, - { ownerKey: `user:${session.user.id}`, signal: req.signal } - ) - - return new NextResponse(new Uint8Array(buffer), { - status: 200, - headers: { - 'Content-Type': 'application/pdf', - 'Content-Length': String(buffer.length), - 'Cache-Control': 'private, no-store', - }, - }) - } catch (err) { - const message = toError(err).message - logger.error('PDF preview generation failed', { error: message, workspaceId }) - return NextResponse.json({ error: message }, { status: 500 }) - } -} +export const POST = createDocumentPreviewRoute({ + taskId: 'pdf-generate', + contentType: 'application/pdf', + label: 'PDF', +}) 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 27f852a1ff6..e189091f122 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -1,67 +1,14 @@ -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 { 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 }) - } - - 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( - '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 = toError(err).message - 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', +})