Skip to content

Commit bc111a6

Browse files
feat(workday): block + tools (#3663)
* checkpoint workday block * add icon svg * fix workday to use soap api * fix SOAP API * address comments * fix * more type fixes * address more comments * fix files * fix file editor useEffect * fix build issue * fix typing * fix test
1 parent 12908c1 commit bc111a6

File tree

35 files changed

+2763
-111
lines changed

35 files changed

+2763
-111
lines changed

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async function handleLocalFile(filename: string, userId: string): Promise<NextRe
102102
throw new FileNotFoundError(`File not found: ${filename}`)
103103
}
104104

105-
const filePath = findLocalFile(filename)
105+
const filePath = await findLocalFile(filename)
106106

107107
if (!filePath) {
108108
throw new FileNotFoundError(`File not found: ${filename}`)
@@ -228,7 +228,7 @@ async function handleCloudProxyPublic(
228228

229229
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
230230
try {
231-
const filePath = findLocalFile(filename)
231+
const filePath = await findLocalFile(filename)
232232

233233
if (!filePath) {
234234
throw new FileNotFoundError(`File not found: ${filename}`)

apps/sim/app/api/files/upload/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export async function POST(request: NextRequest) {
7575
const uploadResults = []
7676

7777
for (const file of files) {
78-
const originalName = file.name || 'untitled'
78+
const originalName = file.name || 'untitled.md'
7979

8080
if (!validateFileExtension(originalName)) {
8181
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'

apps/sim/app/api/files/utils.test.ts

Lines changed: 41 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ describe('extractFilename', () => {
331331

332332
describe('findLocalFile - Path Traversal Security Tests', () => {
333333
describe('path traversal attack prevention', () => {
334-
it.concurrent('should reject classic path traversal attacks', () => {
334+
it.concurrent('should reject classic path traversal attacks', async () => {
335335
const maliciousInputs = [
336336
'../../../etc/passwd',
337337
'..\\..\\..\\windows\\system32\\config\\sam',
@@ -340,79 +340,81 @@ describe('findLocalFile - Path Traversal Security Tests', () => {
340340
'..\\config.ini',
341341
]
342342

343-
maliciousInputs.forEach((input) => {
344-
const result = findLocalFile(input)
343+
for (const input of maliciousInputs) {
344+
const result = await findLocalFile(input)
345345
expect(result).toBeNull()
346-
})
346+
}
347347
})
348348

349-
it.concurrent('should reject encoded path traversal attempts', () => {
349+
it.concurrent('should reject encoded path traversal attempts', async () => {
350350
const encodedInputs = [
351351
'%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd
352352
'..%2f..%2fetc%2fpasswd',
353353
'..%5c..%5cconfig.ini',
354354
]
355355

356-
encodedInputs.forEach((input) => {
357-
const result = findLocalFile(input)
356+
for (const input of encodedInputs) {
357+
const result = await findLocalFile(input)
358358
expect(result).toBeNull()
359-
})
359+
}
360360
})
361361

362-
it.concurrent('should reject mixed path separators', () => {
362+
it.concurrent('should reject mixed path separators', async () => {
363363
const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32']
364364

365-
mixedInputs.forEach((input) => {
366-
const result = findLocalFile(input)
365+
for (const input of mixedInputs) {
366+
const result = await findLocalFile(input)
367367
expect(result).toBeNull()
368-
})
368+
}
369369
})
370370

371-
it.concurrent('should reject filenames with dangerous characters', () => {
371+
it.concurrent('should reject filenames with dangerous characters', async () => {
372372
const dangerousInputs = [
373373
'file:with:colons.txt',
374374
'file|with|pipes.txt',
375375
'file?with?questions.txt',
376376
'file*with*asterisks.txt',
377377
]
378378

379-
dangerousInputs.forEach((input) => {
380-
const result = findLocalFile(input)
379+
for (const input of dangerousInputs) {
380+
const result = await findLocalFile(input)
381381
expect(result).toBeNull()
382-
})
382+
}
383383
})
384384

385-
it.concurrent('should reject null and empty inputs', () => {
386-
expect(findLocalFile('')).toBeNull()
387-
expect(findLocalFile(' ')).toBeNull()
388-
expect(findLocalFile('\t\n')).toBeNull()
385+
it.concurrent('should reject null and empty inputs', async () => {
386+
expect(await findLocalFile('')).toBeNull()
387+
expect(await findLocalFile(' ')).toBeNull()
388+
expect(await findLocalFile('\t\n')).toBeNull()
389389
})
390390

391-
it.concurrent('should reject filenames that become empty after sanitization', () => {
391+
it.concurrent('should reject filenames that become empty after sanitization', async () => {
392392
const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..']
393393

394-
emptyAfterSanitization.forEach((input) => {
395-
const result = findLocalFile(input)
394+
for (const input of emptyAfterSanitization) {
395+
const result = await findLocalFile(input)
396396
expect(result).toBeNull()
397-
})
397+
}
398398
})
399399
})
400400

401401
describe('security validation passes for legitimate files', () => {
402-
it.concurrent('should accept properly formatted filenames without throwing errors', () => {
403-
const legitimateInputs = [
404-
'document.pdf',
405-
'image.png',
406-
'data.csv',
407-
'report-2024.doc',
408-
'file_with_underscores.txt',
409-
'file-with-dashes.json',
410-
]
411-
412-
legitimateInputs.forEach((input) => {
413-
// Should not throw security errors for legitimate filenames
414-
expect(() => findLocalFile(input)).not.toThrow()
415-
})
416-
})
402+
it.concurrent(
403+
'should accept properly formatted filenames without throwing errors',
404+
async () => {
405+
const legitimateInputs = [
406+
'document.pdf',
407+
'image.png',
408+
'data.csv',
409+
'report-2024.doc',
410+
'file_with_underscores.txt',
411+
'file-with-dashes.json',
412+
]
413+
414+
for (const input of legitimateInputs) {
415+
await expect(findLocalFile(input)).resolves.toBeDefined()
416+
}
417+
}
418+
)
417419
})
418420
})

apps/sim/app/api/files/utils.ts

Lines changed: 13 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import { existsSync } from 'fs'
2-
import path from 'path'
31
import { createLogger } from '@sim/logger'
42
import { NextResponse } from 'next/server'
5-
import { UPLOAD_DIR } from '@/lib/uploads/config'
63
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
74

85
const logger = createLogger('FilesUtils')
@@ -123,76 +120,29 @@ export function extractFilename(path: string): string {
123120
return filename
124121
}
125122

126-
function sanitizeFilename(filename: string): string {
127-
if (!filename || typeof filename !== 'string') {
128-
throw new Error('Invalid filename provided')
129-
}
130-
131-
if (!filename.includes('/')) {
132-
throw new Error('File key must include a context prefix (e.g., kb/, workspace/, execution/)')
133-
}
134-
135-
const segments = filename.split('/')
136-
137-
const sanitizedSegments = segments.map((segment) => {
138-
if (segment === '..' || segment === '.') {
139-
throw new Error('Path traversal detected')
140-
}
141-
142-
const sanitized = segment.replace(/\.\./g, '').replace(/[\\]/g, '').replace(/^\./g, '').trim()
143-
144-
if (!sanitized) {
145-
throw new Error('Invalid or empty path segment after sanitization')
146-
}
147-
148-
if (
149-
sanitized.includes(':') ||
150-
sanitized.includes('|') ||
151-
sanitized.includes('?') ||
152-
sanitized.includes('*') ||
153-
sanitized.includes('\x00') ||
154-
/[\x00-\x1F\x7F]/.test(sanitized)
155-
) {
156-
throw new Error('Path segment contains invalid characters')
157-
}
158-
159-
return sanitized
160-
})
161-
162-
return sanitizedSegments.join(path.sep)
163-
}
164-
165-
export function findLocalFile(filename: string): string | null {
123+
export async function findLocalFile(filename: string): Promise<string | null> {
166124
try {
167125
const sanitizedFilename = sanitizeFileKey(filename)
168126

169-
// Reject if sanitized filename is empty or only contains path separators/dots
170127
if (!sanitizedFilename || !sanitizedFilename.trim() || /^[/\\.\s]+$/.test(sanitizedFilename)) {
171128
return null
172129
}
173130

174-
const possiblePaths = [
175-
path.join(UPLOAD_DIR, sanitizedFilename),
176-
path.join(process.cwd(), 'uploads', sanitizedFilename),
177-
]
131+
const { existsSync } = await import('fs')
132+
const path = await import('path')
133+
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server')
178134

179-
for (const filePath of possiblePaths) {
180-
const resolvedPath = path.resolve(filePath)
181-
const allowedDirs = [path.resolve(UPLOAD_DIR), path.resolve(process.cwd(), 'uploads')]
135+
const resolvedPath = path.join(UPLOAD_DIR_SERVER, sanitizedFilename)
182136

183-
// Must be within allowed directory but NOT the directory itself
184-
const isWithinAllowedDir = allowedDirs.some(
185-
(allowedDir) =>
186-
resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir
187-
)
188-
189-
if (!isWithinAllowedDir) {
190-
continue
191-
}
137+
if (
138+
!resolvedPath.startsWith(UPLOAD_DIR_SERVER + path.sep) ||
139+
resolvedPath === UPLOAD_DIR_SERVER
140+
) {
141+
return null
142+
}
192143

193-
if (existsSync(resolvedPath)) {
194-
return resolvedPath
195-
}
144+
if (existsSync(resolvedPath)) {
145+
return resolvedPath
196146
}
197147

198148
return null
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap'
7+
8+
export const dynamic = 'force-dynamic'
9+
10+
const logger = createLogger('WorkdayAssignOnboardingAPI')
11+
12+
const RequestSchema = z.object({
13+
tenantUrl: z.string().min(1),
14+
tenant: z.string().min(1),
15+
username: z.string().min(1),
16+
password: z.string().min(1),
17+
workerId: z.string().min(1),
18+
onboardingPlanId: z.string().min(1),
19+
actionEventId: z.string().min(1),
20+
})
21+
22+
export async function POST(request: NextRequest) {
23+
const requestId = generateRequestId()
24+
25+
try {
26+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
27+
if (!authResult.success) {
28+
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
const body = await request.json()
32+
const data = RequestSchema.parse(body)
33+
34+
const client = await createWorkdaySoapClient(
35+
data.tenantUrl,
36+
data.tenant,
37+
'humanResources',
38+
data.username,
39+
data.password
40+
)
41+
42+
const [result] = await client.Put_Onboarding_Plan_AssignmentAsync({
43+
Onboarding_Plan_Assignment_Data: {
44+
Onboarding_Plan_Reference: wdRef('Onboarding_Plan_ID', data.onboardingPlanId),
45+
Person_Reference: wdRef('WID', data.workerId),
46+
Action_Event_Reference: wdRef('Background_Check_ID', data.actionEventId),
47+
Assignment_Effective_Moment: new Date().toISOString(),
48+
Active: true,
49+
},
50+
})
51+
52+
return NextResponse.json({
53+
success: true,
54+
output: {
55+
assignmentId: extractRefId(result?.Onboarding_Plan_Assignment_Reference),
56+
workerId: data.workerId,
57+
planId: data.onboardingPlanId,
58+
},
59+
})
60+
} catch (error) {
61+
logger.error(`[${requestId}] Workday assign onboarding failed`, { error })
62+
return NextResponse.json(
63+
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
64+
{ status: 500 }
65+
)
66+
}
67+
}

0 commit comments

Comments
 (0)