mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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
This commit is contained in:
134
apps/sim/app/api/tools/agiloft/retrieve/route.ts
Normal file
134
apps/sim/app/api/tools/agiloft/retrieve/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { agiloftLogin, agiloftLogout, buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftRetrieveAPI')
|
||||
|
||||
const AgiloftRetrieveSchema = z.object({
|
||||
instanceUrl: z.string().min(1, 'Instance URL is required'),
|
||||
knowledgeBase: z.string().min(1, 'Knowledge base is required'),
|
||||
login: z.string().min(1, 'Login is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
table: z.string().min(1, 'Table is required'),
|
||||
recordId: z.string().min(1, 'Record ID is required'),
|
||||
fieldName: z.string().min(1, 'Field name is required'),
|
||||
position: z.string().min(1, 'Position is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft retrieve attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const data = AgiloftRetrieveSchema.parse(body)
|
||||
|
||||
const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, {
|
||||
instanceUrl: data.instanceUrl,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: urlValidation.error || 'Invalid instance URL' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const token = await agiloftLogin(data)
|
||||
const base = data.instanceUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const url = buildRetrieveAttachmentUrl(base, data)
|
||||
|
||||
logger.info(`[${requestId}] Downloading attachment from Agiloft`, {
|
||||
recordId: data.recordId,
|
||||
fieldName: data.fieldName,
|
||||
position: data.position,
|
||||
})
|
||||
|
||||
const agiloftResponse = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!agiloftResponse.ok) {
|
||||
const errorText = await agiloftResponse.text()
|
||||
logger.error(
|
||||
`[${requestId}] Agiloft retrieve error: ${agiloftResponse.status} - ${errorText}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Agiloft error: ${agiloftResponse.status} - ${errorText}` },
|
||||
{ status: agiloftResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = agiloftResponse.headers.get('content-type') || 'application/octet-stream'
|
||||
const contentDisposition = agiloftResponse.headers.get('content-disposition')
|
||||
let fileName = 'attachment'
|
||||
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (match?.[1]) {
|
||||
fileName = match[1].replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await agiloftResponse.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
logger.info(`[${requestId}] Attachment downloaded successfully`, {
|
||||
name: fileName,
|
||||
size: fileBuffer.length,
|
||||
mimeType: contentType,
|
||||
})
|
||||
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType: contentType,
|
||||
data: base64Data,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error retrieving Agiloft attachment:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
AgiloftRetrieveAttachmentParams,
|
||||
AgiloftRetrieveAttachmentResponse,
|
||||
} from '@/tools/agiloft/types'
|
||||
import { buildRetrieveAttachmentUrl, executeAgiloftRequest } from '@/tools/agiloft/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const agiloftRetrieveAttachmentTool: ToolConfig<
|
||||
@@ -66,57 +65,47 @@ export const agiloftRetrieveAttachmentTool: ToolConfig<
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://placeholder.agiloft.com',
|
||||
method: 'GET',
|
||||
headers: () => ({}),
|
||||
url: '/api/tools/agiloft/retrieve',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
instanceUrl: params.instanceUrl,
|
||||
knowledgeBase: params.knowledgeBase,
|
||||
login: params.login,
|
||||
password: params.password,
|
||||
table: params.table,
|
||||
recordId: params.recordId,
|
||||
fieldName: params.fieldName,
|
||||
position: params.position,
|
||||
}),
|
||||
},
|
||||
|
||||
directExecution: async (params) => {
|
||||
return executeAgiloftRequest<AgiloftRetrieveAttachmentResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildRetrieveAttachmentUrl(base, params),
|
||||
method: 'GET',
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
file: { name: '', mimeType: '', data: '', size: 0 },
|
||||
},
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
const contentDisposition = response.headers.get('content-disposition')
|
||||
let fileName = 'attachment'
|
||||
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (match?.[1]) {
|
||||
fileName = match[1].replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType: contentType,
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
file: { name: '', mimeType: '', data: '', size: 0 },
|
||||
},
|
||||
error: data.error || 'Failed to retrieve attachment',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: data.output.file.name,
|
||||
mimeType: data.output.file.mimeType,
|
||||
data: data.output.file.data,
|
||||
size: data.output.file.size,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
|
||||
Reference in New Issue
Block a user