Skip to content

Commit b74f8da

Browse files
authored
improvement(sandbox): expand document generation — style extraction, sandbox hardening, OOM errors, task guards (#4526)
* improvement(sandbox): expand document generation — style extraction, sandbox hardening, OOM errors, PPTX/DOCX/PDF task guards * fix(style): make pptx aspect-ratio regex attribute-order independent * fix(sandbox): clarify pptx null-guard message; fix bold=false inheritance sentinel in docx style extractor * chore(lint): suppress noTemplateCurlyInString in resolver tests — strings intentionally assert template literal preservation * fix(contracts): export ListWorkspaceFilesResponse type from workspace-files contract
1 parent 50d4afd commit b74f8da

8 files changed

Lines changed: 427 additions & 109 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,23 @@ const logger = createLogger('WorkspaceFileStyleAPI')
1616

1717
/**
1818
* GET /api/workspaces/[id]/files/[fileId]/style
19-
* Extract a compact JSON style summary from an uploaded .docx or .pptx file.
20-
* Uses OOXML theme XML to return theme colors, font pair, and named styles.
21-
* Only works on binary OOXML files (ZIP format) — not on JS source files.
19+
* Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file.
20+
* OOXML files return theme colors, font pair, and named styles.
21+
* PDF files return page dimensions and embedded font names.
2222
*/
23+
const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB
24+
2325
export const GET = withRouteHandler(
2426
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
25-
const parsed = await parseRequest(workspaceFileStyleContract, request, context)
26-
if (!parsed.success) return parsed.response
27-
const { id: workspaceId, fileId } = parsed.data.params
28-
2927
const session = await getSession()
3028
if (!session?.user?.id) {
3129
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3230
}
3331

32+
const parsed = await parseRequest(workspaceFileStyleContract, request, context)
33+
if (!parsed.success) return parsed.response
34+
const { id: workspaceId, fileId } = parsed.data.params
35+
3436
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
3537
if (!membership) {
3638
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
@@ -42,13 +44,20 @@ export const GET = withRouteHandler(
4244
}
4345

4446
const rawExt = fileRecord.name.split('.').pop()?.toLowerCase()
45-
if (rawExt !== 'docx' && rawExt !== 'pptx') {
47+
if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') {
4648
return NextResponse.json(
47-
{ error: 'Style extraction only supports .docx and .pptx files' },
49+
{ error: 'Style extraction supports .docx, .pptx, and .pdf files' },
50+
{ status: 422 }
51+
)
52+
}
53+
const ext: 'docx' | 'pptx' | 'pdf' = rawExt
54+
55+
if (fileRecord.size > MAX_STYLE_FILE_BYTES) {
56+
return NextResponse.json(
57+
{ error: 'File is too large for style extraction (limit: 100 MB)' },
4858
{ status: 422 }
4959
)
5060
}
51-
const ext: 'docx' | 'pptx' = rawExt
5261

5362
let buffer: Buffer
5463
try {
@@ -66,17 +75,13 @@ export const GET = withRouteHandler(
6675
return NextResponse.json(
6776
{
6877
error:
69-
'File is not a compiled binary document — style extraction requires an uploaded or compiled .docx/.pptx file',
78+
'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information',
7079
},
7180
{ status: 422 }
7281
)
7382
}
7483

75-
logger.info('Extracted style summary via API', {
76-
fileId,
77-
format: ext,
78-
themeName: summary.theme.name,
79-
})
84+
logger.info('Extracted style summary via API', { fileId, format: ext })
8085

8186
return NextResponse.json(summary, {
8287
headers: { 'Cache-Control': 'private, max-age=300' },

apps/sim/executor/variables/resolver.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ describe('VariableResolver function block inputs', () => {
127127
)
128128

129129
expect(result.resolvedInputs.code).toBe(
130+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved
130131
'return `value: ${JSON.stringify(globalThis["__blockRef_0"])}`'
131132
)
132133
expect(result.displayInputs.code).toBe('return `value: "hello world"`')
@@ -139,11 +140,14 @@ describe('VariableResolver function block inputs', () => {
139140
const result = resolver.resolveInputsForFunctionBlock(
140141
ctx,
141142
'function',
143+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved
142144
{ code: 'return `${String(<Producer.result>)}`' },
143145
block
144146
)
145147

148+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved
146149
expect(result.resolvedInputs.code).toBe('return `${String(globalThis["__blockRef_0"])}`')
150+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved
147151
expect(result.displayInputs.code).toBe('return `${String("hello world")}`')
148152
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
149153
})

apps/sim/lib/api/contracts/workspace-files.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from 'zod'
2-
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
2+
import { defineRouteContract } from '@/lib/api/contracts/types'
33

44
export const workspaceFileScopeSchema = z.enum(['active', 'archived', 'all'])
55

@@ -46,19 +46,22 @@ const workspaceFileSuccessSchema = z.object({
4646
success: z.boolean(),
4747
})
4848

49+
const listWorkspaceFilesResponseSchema = workspaceFileSuccessSchema.extend({
50+
files: z.array(workspaceFileRecordSchema),
51+
})
52+
53+
export type ListWorkspaceFilesResponse = z.output<typeof listWorkspaceFilesResponseSchema>
54+
4955
export const listWorkspaceFilesContract = defineRouteContract({
5056
method: 'GET',
5157
path: '/api/workspaces/[id]/files',
5258
params: workspaceFilesParamsSchema,
5359
query: listWorkspaceFilesQuerySchema,
5460
response: {
5561
mode: 'json',
56-
schema: workspaceFileSuccessSchema.extend({
57-
files: z.array(workspaceFileRecordSchema),
58-
}),
62+
schema: listWorkspaceFilesResponseSchema,
5963
},
6064
})
61-
export type ListWorkspaceFilesResponse = ContractJsonResponse<typeof listWorkspaceFilesContract>
6265

6366
export const renameWorkspaceFileContract = defineRouteContract({
6467
method: 'PATCH',
@@ -108,15 +111,30 @@ export const updateWorkspaceFileContentContract = defineRouteContract({
108111

109112
const documentStyleSummarySchema = z
110113
.object({
111-
format: z.enum(['docx', 'pptx']),
114+
format: z.enum(['docx', 'pptx', 'pdf']),
115+
// OOXML theme — present for pptx, present for docx when theme1.xml exists, absent for pdf
112116
theme: z
113117
.object({
114-
name: z.string(),
115118
colors: z.record(z.string(), z.string()),
116119
fonts: z.object({ major: z.string(), minor: z.string() }),
117120
})
118-
.passthrough(),
121+
.optional(),
122+
// docx only
119123
styles: z.array(z.object({}).passthrough()).optional(),
124+
defaults: z.object({ fontSize: z.number().optional(), font: z.string().optional() }).optional(),
125+
// pdf only
126+
pageSize: z
127+
.object({
128+
preset: z.enum(['A4', 'letter', 'custom']),
129+
widthPt: z.number().optional(),
130+
heightPt: z.number().optional(),
131+
})
132+
.optional(),
133+
fonts: z.array(z.string()).optional(),
134+
// pptx only
135+
slideCount: z.number().optional(),
136+
aspectRatio: z.enum(['16:9', '4:3', 'custom']).optional(),
137+
background: z.string().optional(),
120138
})
121139
.passthrough()
122140

0 commit comments

Comments
 (0)