diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index b6c0bbd0a..52aba7473 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -5,14 +5,12 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' -import type { - GraphApiErrorResponse, - GraphChatMessage, - GraphDriveItem, -} from '@/tools/microsoft_teams/types' -import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' +import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' +import { + resolveMentionsForChannel, + type TeamsMention, + uploadFilesForTeamsMessage, +} from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -60,130 +58,12 @@ export async function POST(request: NextRequest) { fileCount: validatedData.files?.length || 0, }) - const attachments: any[] = [] - const filesOutput: Array<{ - name: string - mimeType: string - data: string - size: number - }> = [] - if (validatedData.files && validatedData.files.length > 0) { - const rawFiles = validatedData.files - logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`) - - const userFiles = processFilesToUserFiles(rawFiles, requestId, logger) - - for (const file of userFiles) { - try { - // Microsoft Graph API limits direct uploads to 4MB - const maxSize = 4 * 1024 * 1024 - if (file.size > maxSize) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(2) - logger.error( - `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` - ) - throw new Error( - `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` - ) - } - - logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - filesOutput.push({ - name: file.name, - mimeType: file.type || 'application/octet-stream', - data: buffer.toString('base64'), - size: buffer.length, - }) - - const uploadUrl = - 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + - encodeURIComponent(file.name) + - ':/content' - - logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) - - const uploadResponse = await secureFetchWithValidation( - uploadUrl, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': file.type || 'application/octet-stream', - }, - body: buffer, - }, - 'uploadUrl' - ) - - if (!uploadResponse.ok) { - const errorData = (await uploadResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Teams upload failed:`, errorData) - throw new Error( - `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const uploadedFile = (await uploadResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] File uploaded to Teams successfully`, { - id: uploadedFile.id, - webUrl: uploadedFile.webUrl, - }) - - const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - - const fileDetailsResponse = await secureFetchWithValidation( - fileDetailsUrl, - { - method: 'GET', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - }, - }, - 'fileDetailsUrl' - ) - - if (!fileDetailsResponse.ok) { - const errorData = (await fileDetailsResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Failed to get file details:`, errorData) - throw new Error( - `Failed to get file details: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] Got file details`, { - webDavUrl: fileDetails.webDavUrl, - eTag: fileDetails.eTag, - }) - - const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id - - attachments.push({ - id: attachmentId, - contentType: 'reference', - contentUrl: fileDetails.webDavUrl, - name: file.name, - }) - - logger.info(`[${requestId}] Created attachment reference for ${file.name}`) - } catch (error) { - logger.error(`[${requestId}] Failed to process file ${file.name}:`, error) - throw new Error( - `Failed to process file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } - - logger.info( - `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` - ) - } + const { attachments, filesOutput } = await uploadFilesForTeamsMessage({ + rawFiles: validatedData.files || [], + accessToken: validatedData.accessToken, + requestId, + logger, + }) let messageContent = validatedData.content let contentType: 'text' | 'html' = 'text' diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index ec8d43d8a..4d12a4e9c 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -5,14 +5,12 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' -import type { - GraphApiErrorResponse, - GraphChatMessage, - GraphDriveItem, -} from '@/tools/microsoft_teams/types' -import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' +import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' +import { + resolveMentionsForChat, + type TeamsMention, + uploadFilesForTeamsMessage, +} from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -58,130 +56,12 @@ export async function POST(request: NextRequest) { fileCount: validatedData.files?.length || 0, }) - const attachments: any[] = [] - const filesOutput: Array<{ - name: string - mimeType: string - data: string - size: number - }> = [] - if (validatedData.files && validatedData.files.length > 0) { - const rawFiles = validatedData.files - logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to Teams`) - - const userFiles = processFilesToUserFiles(rawFiles, requestId, logger) - - for (const file of userFiles) { - try { - // Microsoft Graph API limits direct uploads to 4MB - const maxSize = 4 * 1024 * 1024 - if (file.size > maxSize) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(2) - logger.error( - `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` - ) - throw new Error( - `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` - ) - } - - logger.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - filesOutput.push({ - name: file.name, - mimeType: file.type || 'application/octet-stream', - data: buffer.toString('base64'), - size: buffer.length, - }) - - const uploadUrl = - 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + - encodeURIComponent(file.name) + - ':/content' - - logger.info(`[${requestId}] Uploading to Teams: ${uploadUrl}`) - - const uploadResponse = await secureFetchWithValidation( - uploadUrl, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': file.type || 'application/octet-stream', - }, - body: buffer, - }, - 'uploadUrl' - ) - - if (!uploadResponse.ok) { - const errorData = (await uploadResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Teams upload failed:`, errorData) - throw new Error( - `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const uploadedFile = (await uploadResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] File uploaded to Teams successfully`, { - id: uploadedFile.id, - webUrl: uploadedFile.webUrl, - }) - - const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` - - const fileDetailsResponse = await secureFetchWithValidation( - fileDetailsUrl, - { - method: 'GET', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - }, - }, - 'fileDetailsUrl' - ) - - if (!fileDetailsResponse.ok) { - const errorData = (await fileDetailsResponse - .json() - .catch(() => ({}))) as GraphApiErrorResponse - logger.error(`[${requestId}] Failed to get file details:`, errorData) - throw new Error( - `Failed to get file details: ${errorData.error?.message || 'Unknown error'}` - ) - } - - const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem - logger.info(`[${requestId}] Got file details`, { - webDavUrl: fileDetails.webDavUrl, - eTag: fileDetails.eTag, - }) - - const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id - - attachments.push({ - id: attachmentId, - contentType: 'reference', - contentUrl: fileDetails.webDavUrl, - name: file.name, - }) - - logger.info(`[${requestId}] Created attachment reference for ${file.name}`) - } catch (error) { - logger.error(`[${requestId}] Failed to process file ${file.name}:`, error) - throw new Error( - `Failed to process file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } - - logger.info( - `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` - ) - } + const { attachments, filesOutput } = await uploadFilesForTeamsMessage({ + rawFiles: validatedData.files || [], + accessToken: validatedData.accessToken, + requestId, + logger, + }) let messageContent = validatedData.content let contentType: 'text' | 'html' = 'text' diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts index 906f869d2..39dc9259a 100644 --- a/apps/sim/app/api/tools/pulse/parse/route.ts +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -7,15 +7,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { type StorageContext, StorageService } from '@/lib/uploads' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' -import { - inferContextFromKey, - isInternalFileUrl, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-utils' -import { resolveInternalFileUrl } from '@/lib/uploads/utils/file-utils.server' -import { verifyFileAccess } from '@/app/api/files/authorization' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' export const dynamic = 'force-dynamic' @@ -56,120 +50,31 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = PulseParseSchema.parse(body) - const fileInput = validatedData.file - let fileUrl = '' - if (fileInput) { - logger.info(`[${requestId}] Pulse parse request`, { - fileName: fileInput.name, - userId, - }) + logger.info(`[${requestId}] Pulse parse request`, { + fileName: validatedData.file?.name, + filePath: validatedData.filePath, + isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false, + userId, + }) - let userFile - try { - userFile = processSingleFileToUserFile(fileInput, requestId, logger) - } catch (error) { - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to process file', - }, - { status: 400 } - ) - } + const resolution = await resolveFileInputToUrl({ + file: validatedData.file, + filePath: validatedData.filePath, + userId, + requestId, + logger, + }) - fileUrl = userFile.url || '' - if (fileUrl && isInternalFileUrl(fileUrl)) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || '' - } - if (!fileUrl && userFile.key) { - const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) - const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: userFile.key, - context, - }) - return NextResponse.json( - { - success: false, - error: 'File not found', - }, - { status: 404 } - ) - } - fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) - } - } else if (validatedData.filePath) { - logger.info(`[${requestId}] Pulse parse request`, { - filePath: validatedData.filePath, - isWorkspaceFile: isInternalFileUrl(validatedData.filePath), - userId, - }) - - fileUrl = validatedData.filePath - const isInternalFilePath = isInternalFileUrl(validatedData.filePath) - if (isInternalFilePath) { - const resolution = await resolveInternalFileUrl( - validatedData.filePath, - userId, - requestId, - logger - ) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || fileUrl - } else if (validatedData.filePath.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: validatedData.filePath.substring(0, 50), - }) - return NextResponse.json( - { - success: false, - error: 'Invalid file path. Only uploaded files are supported for internal paths.', - }, - { status: 400 } - ) - } else { - const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') - if (!urlValidation.isValid) { - return NextResponse.json( - { - success: false, - error: urlValidation.error, - }, - { status: 400 } - ) - } - } + if (resolution.error) { + return NextResponse.json( + { success: false, error: resolution.error.message }, + { status: resolution.error.status } + ) } + const fileUrl = resolution.fileUrl if (!fileUrl) { - return NextResponse.json( - { - success: false, - error: 'File input is required', - }, - { status: 400 } - ) + return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 }) } const formData = new FormData() diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index 089733043..c526c8f2a 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -7,15 +7,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' -import { type StorageContext, StorageService } from '@/lib/uploads' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' -import { - inferContextFromKey, - isInternalFileUrl, - processSingleFileToUserFile, -} from '@/lib/uploads/utils/file-utils' -import { resolveInternalFileUrl } from '@/lib/uploads/utils/file-utils.server' -import { verifyFileAccess } from '@/app/api/files/authorization' +import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' export const dynamic = 'force-dynamic' @@ -52,122 +46,31 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = ReductoParseSchema.parse(body) - const fileInput = validatedData.file - let fileUrl = '' - if (fileInput) { - logger.info(`[${requestId}] Reducto parse request`, { - fileName: fileInput.name, - userId, - }) + logger.info(`[${requestId}] Reducto parse request`, { + fileName: validatedData.file?.name, + filePath: validatedData.filePath, + isWorkspaceFile: validatedData.filePath ? isInternalFileUrl(validatedData.filePath) : false, + userId, + }) - let userFile - try { - userFile = processSingleFileToUserFile(fileInput, requestId, logger) - } catch (error) { - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to process file', - }, - { status: 400 } - ) - } + const resolution = await resolveFileInputToUrl({ + file: validatedData.file, + filePath: validatedData.filePath, + userId, + requestId, + logger, + }) - fileUrl = userFile.url || '' - if (fileUrl && isInternalFileUrl(fileUrl)) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || '' - } - if (!fileUrl && userFile.key) { - const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) - const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) - - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: userFile.key, - context, - }) - return NextResponse.json( - { - success: false, - error: 'File not found', - }, - { status: 404 } - ) - } - - fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) - } - } else if (validatedData.filePath) { - logger.info(`[${requestId}] Reducto parse request`, { - filePath: validatedData.filePath, - isWorkspaceFile: isInternalFileUrl(validatedData.filePath), - userId, - }) - - fileUrl = validatedData.filePath - const isInternalFilePath = isInternalFileUrl(validatedData.filePath) - if (isInternalFilePath) { - const resolution = await resolveInternalFileUrl( - validatedData.filePath, - userId, - requestId, - logger - ) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || fileUrl - } else if (validatedData.filePath.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: validatedData.filePath.substring(0, 50), - }) - return NextResponse.json( - { - success: false, - error: 'Invalid file path. Only uploaded files are supported for internal paths.', - }, - { status: 400 } - ) - } else { - const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') - if (!urlValidation.isValid) { - return NextResponse.json( - { - success: false, - error: urlValidation.error, - }, - { status: 400 } - ) - } - } + if (resolution.error) { + return NextResponse.json( + { success: false, error: resolution.error.message }, + { status: resolution.error.status } + ) } + const fileUrl = resolution.fileUrl if (!fileUrl) { - return NextResponse.json( - { - success: false, - error: 'File input is required', - }, - { status: 400 } - ) + return NextResponse.json({ success: false, error: 'File input is required' }, { status: 400 }) } const reductoBody: Record = { diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 9330d4da4..cab959741 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -575,8 +575,8 @@ async function transcribeWithAssemblyAI( audio_url: upload_url, } - // AssemblyAI only supports 'best', 'slam-1', or 'universal' for speech_model - if (model === 'best') { + // AssemblyAI supports 'best', 'slam-1', or 'universal' for speech_model + if (model === 'best' || model === 'slam-1' || model === 'universal') { transcriptRequest.speech_model = model } diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 440b2d92c..b759918d0 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -12,10 +12,124 @@ import { extractStorageKey, inferContextFromKey, isInternalFileUrl, + processSingleFileToUserFile, + type RawFileInput, } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' +/** + * Result type for file input resolution + */ +export interface FileResolutionResult { + fileUrl?: string + error?: { + status: number + message: string + } +} + +/** + * Options for resolving file input to a URL + */ +export interface ResolveFileInputOptions { + file?: RawFileInput + filePath?: string + userId: string + requestId: string + logger: Logger +} + +/** + * Resolves file input (either a file object or filePath string) to a publicly accessible URL. + * Handles: + * - Processing raw file input via processSingleFileToUserFile + * - Resolving internal URLs via resolveInternalFileUrl + * - Generating presigned URLs for storage keys + * - Validating external URLs via validateUrlWithDNS + */ +export async function resolveFileInputToUrl( + options: ResolveFileInputOptions +): Promise { + const { file, filePath, userId, requestId, logger } = options + + if (file) { + let userFile: UserFile + try { + userFile = processSingleFileToUserFile(file, requestId, logger) + } catch (error) { + return { + error: { + status: 400, + message: error instanceof Error ? error.message : 'Failed to process file', + }, + } + } + + let fileUrl = userFile.url || '' + + // Handle internal URLs + if (fileUrl && isInternalFileUrl(fileUrl)) { + const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + if (resolution.error) { + return { error: resolution.error } + } + fileUrl = resolution.fileUrl || '' + } + + // Generate presigned URL if we have a key but no URL + if (!fileUrl && userFile.key) { + const context = (userFile.context as StorageContext) || inferContextFromKey(userFile.key) + const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: userFile.key, + context, + }) + return { error: { status: 404, message: 'File not found' } } + } + + fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) + } + + return { fileUrl } + } + + if (filePath) { + let fileUrl = filePath + + if (isInternalFileUrl(filePath)) { + const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger) + if (resolution.error) { + return { error: resolution.error } + } + fileUrl = resolution.fileUrl || fileUrl + } else if (filePath.startsWith('/')) { + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: filePath.substring(0, 50), + }) + return { + error: { + status: 400, + message: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + } + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'filePath') + if (!urlValidation.isValid) { + return { error: { status: 400, message: urlValidation.error || 'Invalid URL' } } + } + } + + return { fileUrl } + } + + return { error: { status: 400, message: 'File input is required' } } +} + /** * Download a file from a URL (internal or external) * For internal URLs, uses direct storage access (server-side only) diff --git a/apps/sim/tools/microsoft_teams/utils.ts b/apps/sim/tools/microsoft_teams/utils.ts index 501457471..5e14a0834 100644 --- a/apps/sim/tools/microsoft_teams/utils.ts +++ b/apps/sim/tools/microsoft_teams/utils.ts @@ -1,9 +1,172 @@ +import type { Logger } from '@sim/logger' import { createLogger } from '@sim/logger' -import type { MicrosoftTeamsAttachment } from '@/tools/microsoft_teams/types' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import type { UserFile } from '@/executor/types' +import type { + GraphApiErrorResponse, + GraphDriveItem, + MicrosoftTeamsAttachment, +} from '@/tools/microsoft_teams/types' import type { ToolFileData } from '@/tools/types' const logger = createLogger('MicrosoftTeamsUtils') +/** Maximum file size for Teams direct upload (4MB) */ +const MAX_TEAMS_FILE_SIZE = 4 * 1024 * 1024 + +/** Output format for uploaded files */ +export interface TeamsFileOutput { + name: string + mimeType: string + data: string + size: number +} + +/** Attachment reference for Teams message */ +export interface TeamsAttachmentRef { + id: string + contentType: 'reference' + contentUrl: string + name: string +} + +/** Result from processing and uploading files for Teams */ +export interface TeamsFileUploadResult { + attachments: TeamsAttachmentRef[] + filesOutput: TeamsFileOutput[] +} + +/** + * Process and upload files to OneDrive for Teams message attachments. + * Handles size validation, downloading from storage, uploading to OneDrive, + * and creating attachment references. + */ +export async function uploadFilesForTeamsMessage(params: { + rawFiles: unknown[] + accessToken: string + requestId: string + logger: Logger +}): Promise { + const { rawFiles, accessToken, requestId, logger: log } = params + const attachments: TeamsAttachmentRef[] = [] + const filesOutput: TeamsFileOutput[] = [] + + if (!rawFiles || rawFiles.length === 0) { + return { attachments, filesOutput } + } + + log.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`) + + const userFiles = processFilesToUserFiles(rawFiles, requestId, log) as UserFile[] + + for (const file of userFiles) { + // Check size limit + if (file.size > MAX_TEAMS_FILE_SIZE) { + const sizeMB = (file.size / (1024 * 1024)).toFixed(2) + log.error( + `[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload` + ) + throw new Error( + `File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.` + ) + } + + log.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`) + + // Download file from storage + const buffer = await downloadFileFromStorage(file, requestId, log) + filesOutput.push({ + name: file.name, + mimeType: file.type || 'application/octet-stream', + data: buffer.toString('base64'), + size: buffer.length, + }) + + // Upload to OneDrive + const uploadUrl = + 'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' + + encodeURIComponent(file.name) + + ':/content' + + log.info(`[${requestId}] Uploading to OneDrive: ${uploadUrl}`) + + const uploadResponse = await secureFetchWithValidation( + uploadUrl, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': file.type || 'application/octet-stream', + }, + body: buffer, + }, + 'uploadUrl' + ) + + if (!uploadResponse.ok) { + const errorData = (await uploadResponse.json().catch(() => ({}))) as GraphApiErrorResponse + log.error(`[${requestId}] Teams upload failed:`, errorData) + throw new Error( + `Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}` + ) + } + + const uploadedFile = (await uploadResponse.json()) as GraphDriveItem + log.info(`[${requestId}] File uploaded to OneDrive successfully`, { + id: uploadedFile.id, + webUrl: uploadedFile.webUrl, + }) + + // Get file details for attachment reference + const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?$select=id,name,webDavUrl,eTag,size` + + const fileDetailsResponse = await secureFetchWithValidation( + fileDetailsUrl, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + 'fileDetailsUrl' + ) + + if (!fileDetailsResponse.ok) { + const errorData = (await fileDetailsResponse + .json() + .catch(() => ({}))) as GraphApiErrorResponse + log.error(`[${requestId}] Failed to get file details:`, errorData) + throw new Error(`Failed to get file details: ${errorData.error?.message || 'Unknown error'}`) + } + + const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem + log.info(`[${requestId}] Got file details`, { + webDavUrl: fileDetails.webDavUrl, + eTag: fileDetails.eTag, + }) + + // Create attachment reference + const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id + + attachments.push({ + id: attachmentId, + contentType: 'reference', + contentUrl: fileDetails.webDavUrl!, + name: file.name, + }) + + log.info(`[${requestId}] Created attachment reference for ${file.name}`) + } + + log.info( + `[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created` + ) + + return { attachments, filesOutput } +} + interface ParsedMention { name: string fullTag: string