Skip to content

Commit 5ea64e9

Browse files
committed
feat(agiloft): add API route for retrieve_attachment, matching established file patterns
Convert retrieve_attachment from directExecution to standard API route pattern, consistent with Slack download and Google Drive download tools. - Create /api/tools/agiloft/retrieve with DNS validation, auth lifecycle, and base64 file response matching the { file: { name, mimeType, data, size } } convention - Update retrieve_attachment tool to use request/transformResponse instead of directExecution, removing the dependency on executeAgiloftRequest from the tool definition - File output type: 'file' enables FileToolProcessor to store downloaded files in execution filesystem automatically
1 parent 5d986b2 commit 5ea64e9

File tree

2 files changed

+171
-48
lines changed

2 files changed

+171
-48
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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 { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { agiloftLogin, agiloftLogout, buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils'
8+
9+
export const dynamic = 'force-dynamic'
10+
11+
const logger = createLogger('AgiloftRetrieveAPI')
12+
13+
const AgiloftRetrieveSchema = z.object({
14+
instanceUrl: z.string().min(1, 'Instance URL is required'),
15+
knowledgeBase: z.string().min(1, 'Knowledge base is required'),
16+
login: z.string().min(1, 'Login is required'),
17+
password: z.string().min(1, 'Password is required'),
18+
table: z.string().min(1, 'Table is required'),
19+
recordId: z.string().min(1, 'Record ID is required'),
20+
fieldName: z.string().min(1, 'Field name is required'),
21+
position: z.string().min(1, 'Position is required'),
22+
})
23+
24+
export async function POST(request: NextRequest) {
25+
const requestId = generateRequestId()
26+
27+
try {
28+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
29+
30+
if (!authResult.success) {
31+
logger.warn(`[${requestId}] Unauthorized Agiloft retrieve attempt: ${authResult.error}`)
32+
return NextResponse.json(
33+
{ success: false, error: authResult.error || 'Authentication required' },
34+
{ status: 401 }
35+
)
36+
}
37+
38+
const body = await request.json()
39+
const data = AgiloftRetrieveSchema.parse(body)
40+
41+
const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl')
42+
if (!urlValidation.isValid) {
43+
logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, {
44+
instanceUrl: data.instanceUrl,
45+
})
46+
return NextResponse.json(
47+
{ success: false, error: urlValidation.error || 'Invalid instance URL' },
48+
{ status: 400 }
49+
)
50+
}
51+
52+
const token = await agiloftLogin(data)
53+
const base = data.instanceUrl.replace(/\/$/, '')
54+
55+
try {
56+
const url = buildRetrieveAttachmentUrl(base, data)
57+
58+
logger.info(`[${requestId}] Downloading attachment from Agiloft`, {
59+
recordId: data.recordId,
60+
fieldName: data.fieldName,
61+
position: data.position,
62+
})
63+
64+
const agiloftResponse = await fetch(url, {
65+
method: 'GET',
66+
headers: {
67+
Authorization: `Bearer ${token}`,
68+
},
69+
})
70+
71+
if (!agiloftResponse.ok) {
72+
const errorText = await agiloftResponse.text()
73+
logger.error(
74+
`[${requestId}] Agiloft retrieve error: ${agiloftResponse.status} - ${errorText}`
75+
)
76+
return NextResponse.json(
77+
{ success: false, error: `Agiloft error: ${agiloftResponse.status} - ${errorText}` },
78+
{ status: agiloftResponse.status }
79+
)
80+
}
81+
82+
const contentType = agiloftResponse.headers.get('content-type') || 'application/octet-stream'
83+
const contentDisposition = agiloftResponse.headers.get('content-disposition')
84+
let fileName = 'attachment'
85+
86+
if (contentDisposition) {
87+
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
88+
if (match?.[1]) {
89+
fileName = match[1].replace(/['"]/g, '')
90+
}
91+
}
92+
93+
const arrayBuffer = await agiloftResponse.arrayBuffer()
94+
const fileBuffer = Buffer.from(arrayBuffer)
95+
96+
logger.info(`[${requestId}] Attachment downloaded successfully`, {
97+
name: fileName,
98+
size: fileBuffer.length,
99+
mimeType: contentType,
100+
})
101+
102+
const base64Data = fileBuffer.toString('base64')
103+
104+
return NextResponse.json({
105+
success: true,
106+
output: {
107+
file: {
108+
name: fileName,
109+
mimeType: contentType,
110+
data: base64Data,
111+
size: fileBuffer.length,
112+
},
113+
},
114+
})
115+
} finally {
116+
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token)
117+
}
118+
} catch (error) {
119+
if (error instanceof z.ZodError) {
120+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
121+
return NextResponse.json(
122+
{ success: false, error: 'Invalid request data', details: error.errors },
123+
{ status: 400 }
124+
)
125+
}
126+
127+
logger.error(`[${requestId}] Error retrieving Agiloft attachment:`, error)
128+
129+
return NextResponse.json(
130+
{ success: false, error: error instanceof Error ? error.message : 'Internal server error' },
131+
{ status: 500 }
132+
)
133+
}
134+
}

apps/sim/tools/agiloft/retrieve_attachment.ts

Lines changed: 37 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {
22
AgiloftRetrieveAttachmentParams,
33
AgiloftRetrieveAttachmentResponse,
44
} from '@/tools/agiloft/types'
5-
import { buildRetrieveAttachmentUrl, executeAgiloftRequest } from '@/tools/agiloft/utils'
65
import type { ToolConfig } from '@/tools/types'
76

87
export const agiloftRetrieveAttachmentTool: ToolConfig<
@@ -66,57 +65,47 @@ export const agiloftRetrieveAttachmentTool: ToolConfig<
6665
},
6766

6867
request: {
69-
url: 'https://placeholder.agiloft.com',
70-
method: 'GET',
71-
headers: () => ({}),
68+
url: '/api/tools/agiloft/retrieve',
69+
method: 'POST',
70+
headers: () => ({
71+
'Content-Type': 'application/json',
72+
}),
73+
body: (params) => ({
74+
instanceUrl: params.instanceUrl,
75+
knowledgeBase: params.knowledgeBase,
76+
login: params.login,
77+
password: params.password,
78+
table: params.table,
79+
recordId: params.recordId,
80+
fieldName: params.fieldName,
81+
position: params.position,
82+
}),
7283
},
7384

74-
directExecution: async (params) => {
75-
return executeAgiloftRequest<AgiloftRetrieveAttachmentResponse>(
76-
params,
77-
(base) => ({
78-
url: buildRetrieveAttachmentUrl(base, params),
79-
method: 'GET',
80-
}),
81-
async (response) => {
82-
if (!response.ok) {
83-
const errorText = await response.text()
84-
return {
85-
success: false,
86-
output: {
87-
file: { name: '', mimeType: '', data: '', size: 0 },
88-
},
89-
error: `Agiloft error: ${response.status} - ${errorText}`,
90-
}
91-
}
85+
transformResponse: async (response: Response) => {
86+
const data = await response.json()
9287

93-
const contentType = response.headers.get('content-type') || 'application/octet-stream'
94-
const contentDisposition = response.headers.get('content-disposition')
95-
let fileName = 'attachment'
96-
97-
if (contentDisposition) {
98-
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
99-
if (match?.[1]) {
100-
fileName = match[1].replace(/['"]/g, '')
101-
}
102-
}
103-
104-
const arrayBuffer = await response.arrayBuffer()
105-
const buffer = Buffer.from(arrayBuffer)
106-
107-
return {
108-
success: true,
109-
output: {
110-
file: {
111-
name: fileName,
112-
mimeType: contentType,
113-
data: buffer.toString('base64'),
114-
size: buffer.length,
115-
},
116-
},
117-
}
88+
if (!data.success) {
89+
return {
90+
success: false,
91+
output: {
92+
file: { name: '', mimeType: '', data: '', size: 0 },
93+
},
94+
error: data.error || 'Failed to retrieve attachment',
11895
}
119-
)
96+
}
97+
98+
return {
99+
success: true,
100+
output: {
101+
file: {
102+
name: data.output.file.name,
103+
mimeType: data.output.file.mimeType,
104+
data: data.output.file.data,
105+
size: data.output.file.size,
106+
},
107+
},
108+
}
120109
},
121110

122111
outputs: {

0 commit comments

Comments
 (0)