diff --git a/apps/docs/content/docs/en/tools/microsoft_excel.mdx b/apps/docs/content/docs/en/tools/microsoft_excel.mdx index 4da61a5b9cd..08733e5eb30 100644 --- a/apps/docs/content/docs/en/tools/microsoft_excel.mdx +++ b/apps/docs/content/docs/en/tools/microsoft_excel.mdx @@ -45,6 +45,7 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook to read from \(e.g., "01ABC123DEF456"\) | +| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. | | `range` | string | No | The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet. | #### Output @@ -67,6 +68,7 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook to write to \(e.g., "01ABC123DEF456"\) | +| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. | | `range` | string | No | The range of cells to write to \(e.g., "Sheet1!A1:B2"\) | | `values` | array | Yes | The data to write as a 2D array \(e.g., \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects | | `valueInputOption` | string | No | The format of the data to write | diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts index 23bd2e57e5e..a6e6add80f3 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -19,6 +20,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const credentialId = searchParams.get('credentialId') const query = searchParams.get('query') || '' + const driveId = searchParams.get('driveId') || undefined const workflowId = searchParams.get('workflowId') || undefined if (!credentialId) { @@ -72,8 +74,21 @@ export async function GET(request: NextRequest) { ) searchParams_new.append('$top', '50') + // When driveId is provided (SharePoint), search within that specific drive. + // Otherwise, search the user's personal OneDrive. + if (driveId) { + const driveIdValidation = validatePathSegment(driveId, { + paramName: 'driveId', + customPattern: /^[a-zA-Z0-9!_-]+$/, + }) + if (!driveIdValidation.isValid) { + return NextResponse.json({ error: driveIdValidation.error }, { status: 400 }) + } + } + const drivePath = driveId ? `drives/${driveId}` : 'me/drive' + const response = await fetch( - `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`, + `https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`, { headers: { Authorization: `Bearer ${accessToken}`, diff --git a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts new file mode 100644 index 00000000000..d9c9aa845bd --- /dev/null +++ b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts @@ -0,0 +1,134 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('MicrosoftExcelDrivesAPI') + +interface GraphDrive { + id: string + name: string + driveType: string + webUrl?: string +} + +/** + * List document libraries (drives) for a SharePoint site. + * Used by the microsoft.excel.drives selector to let users pick + * which drive contains their Excel file. + */ +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const body = await request.json() + const { credential, workflowId, siteId, driveId } = body + + if (!credential) { + logger.warn(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!siteId) { + logger.warn(`[${requestId}] Missing siteId in request`) + return NextResponse.json({ error: 'Site ID is required' }, { status: 400 }) + } + + const siteIdValidation = validateSharePointSiteId(siteId, 'siteId') + if (!siteIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid siteId format`) + return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.warn(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) + } + + // Single-drive lookup when driveId is provided (used by fetchById) + if (driveId) { + const driveIdValidation = validatePathSegment(driveId, { + paramName: 'driveId', + customPattern: /^[a-zA-Z0-9!_-]+$/, + }) + if (!driveIdValidation.isValid) { + return NextResponse.json({ error: driveIdValidation.error }, { status: 400 }) + } + + const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives/${driveId}?$select=id,name,driveType,webUrl` + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch drive' }, + { status: response.status } + ) + } + + const data: GraphDrive = await response.json() + return NextResponse.json( + { drive: { id: data.id, name: data.name, driveType: data.driveType } }, + { status: 200 } + ) + } + + // List all drives for the site + const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=id,name,driveType,webUrl` + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, { + status: response.status, + error: errorData.error?.message, + }) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch drives' }, + { status: response.status } + ) + } + + const data = await response.json() + const drives = (data.value || []).map((drive: GraphDrive) => ({ + id: drive.id, + name: drive.name, + driveType: drive.driveType, + })) + + logger.info(`[${requestId}] Successfully fetched ${drives.length} drives for site ${siteId}`) + return NextResponse.json({ drives }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching drives`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts index d4f8035149e..367e04fc413 100644 --- a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getItemBasePath } from '@/tools/microsoft_excel/utils' export const dynamic = 'force-dynamic' @@ -30,6 +31,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const credentialId = searchParams.get('credentialId') const spreadsheetId = searchParams.get('spreadsheetId') + const driveId = searchParams.get('driveId') || undefined const workflowId = searchParams.get('workflowId') || undefined if (!credentialId) { @@ -61,17 +63,23 @@ export async function GET(request: NextRequest) { `[${requestId}] Fetching worksheets from Microsoft Graph API for workbook ${spreadsheetId}` ) - // Fetch worksheets from Microsoft Graph API - const worksheetsResponse = await fetch( - `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - } - ) + let basePath: string + try { + basePath = getItemBasePath(spreadsheetId, driveId) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Invalid parameters' }, + { status: 400 } + ) + } + + const worksheetsResponse = await fetch(`${basePath}/workbook/worksheets`, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) if (!worksheetsResponse.ok) { const errorData = await worksheetsResponse diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index 4b04368742c..8106d234251 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -68,6 +68,13 @@ export const MicrosoftExcelBlock: BlockConfig = { dependsOn: ['credential'], mode: 'basic', }, + { + id: 'driveId', + title: 'Drive ID (SharePoint)', + type: 'short-input', + placeholder: 'Leave empty for OneDrive, or enter drive ID for SharePoint', + mode: 'advanced', + }, { id: 'manualSpreadsheetId', title: 'Spreadsheet ID', @@ -249,9 +256,17 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, } }, params: (params) => { - const { oauthCredential, values, spreadsheetId, tableName, worksheetName, ...rest } = params + const { + oauthCredential, + values, + spreadsheetId, + tableName, + worksheetName, + driveId, + siteId: _siteId, + ...rest + } = params - // Use canonical param ID (raw subBlock IDs are deleted after serialization) const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : '' let parsedValues @@ -276,6 +291,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, const baseParams = { ...rest, spreadsheetId: effectiveSpreadsheetId, + driveId: driveId ? String(driveId).trim() : undefined, values: parsedValues, oauthCredential, } @@ -302,6 +318,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, operation: { type: 'string', description: 'Operation to perform' }, oauthCredential: { type: 'string', description: 'Microsoft Excel access token' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, + driveId: { type: 'string', description: 'Drive ID for SharePoint document libraries' }, range: { type: 'string', description: 'Cell range' }, tableName: { type: 'string', description: 'Table name' }, worksheetName: { type: 'string', description: 'Worksheet name' }, @@ -377,6 +394,47 @@ export const MicrosoftExcelV2Block: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, + // File Source selector (both modes) + { + id: 'fileSource', + title: 'File Source', + type: 'dropdown', + options: [ + { label: 'OneDrive', id: 'onedrive' }, + { label: 'SharePoint', id: 'sharepoint' }, + ], + value: () => 'onedrive', + }, + // SharePoint Site Selector (basic mode, only when SharePoint is selected) + { + id: 'siteSelector', + title: 'SharePoint Site', + type: 'file-selector', + canonicalParamId: 'siteId', + serviceId: 'sharepoint', + selectorKey: 'sharepoint.sites', + requiredScopes: [], + placeholder: 'Select a SharePoint site', + dependsOn: ['credential', 'fileSource'], + condition: { field: 'fileSource', value: 'sharepoint' }, + required: { field: 'fileSource', value: 'sharepoint' }, + mode: 'basic', + }, + // SharePoint Drive Selector (basic mode, only when SharePoint is selected) + { + id: 'driveSelector', + title: 'Document Library', + type: 'file-selector', + canonicalParamId: 'driveId', + serviceId: 'microsoft-excel', + selectorKey: 'microsoft.excel.drives', + selectorAllowSearch: false, + placeholder: 'Select a document library', + dependsOn: ['credential', 'siteSelector', 'fileSource'], + condition: { field: 'fileSource', value: 'sharepoint' }, + required: { field: 'fileSource', value: 'sharepoint' }, + mode: 'basic', + }, // Spreadsheet Selector (basic mode) { id: 'spreadsheetId', @@ -388,9 +446,20 @@ export const MicrosoftExcelV2Block: BlockConfig = { requiredScopes: [], mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', placeholder: 'Select a spreadsheet', - dependsOn: ['credential'], + dependsOn: { all: ['credential', 'fileSource'], any: ['credential', 'driveSelector'] }, mode: 'basic', }, + // Drive ID for SharePoint (advanced mode, only when SharePoint is selected) + { + id: 'manualDriveId', + title: 'Drive ID', + type: 'short-input', + canonicalParamId: 'driveId', + placeholder: 'Enter the SharePoint drive ID', + condition: { field: 'fileSource', value: 'sharepoint' }, + dependsOn: ['fileSource'], + mode: 'advanced', + }, // Manual Spreadsheet ID (advanced mode) { id: 'manualSpreadsheetId', @@ -398,7 +467,7 @@ export const MicrosoftExcelV2Block: BlockConfig = { type: 'short-input', canonicalParamId: 'spreadsheetId', placeholder: 'Enter spreadsheet ID', - dependsOn: ['credential'], + dependsOn: { all: ['credential'], any: ['credential', 'manualDriveId'] }, mode: 'advanced', }, // Sheet Name Selector (basic mode) @@ -412,7 +481,10 @@ export const MicrosoftExcelV2Block: BlockConfig = { selectorAllowSearch: false, placeholder: 'Select a sheet', required: true, - dependsOn: { all: ['credential'], any: ['spreadsheetId', 'manualSpreadsheetId'] }, + dependsOn: { + all: ['credential'], + any: ['spreadsheetId', 'manualSpreadsheetId', 'driveSelector'], + }, mode: 'basic', }, // Manual Sheet Name (advanced mode) @@ -423,7 +495,10 @@ export const MicrosoftExcelV2Block: BlockConfig = { canonicalParamId: 'sheetName', placeholder: 'Name of the sheet/tab (e.g., Sheet1)', required: true, - dependsOn: ['credential'], + dependsOn: { + all: ['credential'], + any: ['credential', 'manualDriveId'], + }, mode: 'advanced', }, // Cell Range (optional for read/write) @@ -514,11 +589,20 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, fallbackToolId: 'microsoft_excel_read_v2', }), params: (params) => { - const { oauthCredential, values, spreadsheetId, sheetName, cellRange, ...rest } = params + const { + oauthCredential, + values, + spreadsheetId, + sheetName, + cellRange, + driveId, + siteId: _siteId, + fileSource: _fileSource, + ...rest + } = params const parsedValues = values ? JSON.parse(values as string) : undefined - // Use canonical param IDs (raw subBlock IDs are deleted after serialization) const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : '' const effectiveSheetName = sheetName ? String(sheetName).trim() : '' @@ -535,6 +619,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, spreadsheetId: effectiveSpreadsheetId, sheetName: effectiveSheetName, cellRange: cellRange ? (cellRange as string).trim() : undefined, + driveId: driveId ? String(driveId).trim() : undefined, values: parsedValues, oauthCredential, } @@ -543,7 +628,10 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, + fileSource: { type: 'string', description: 'File source (onedrive or sharepoint)' }, oauthCredential: { type: 'string', description: 'Microsoft Excel access token' }, + siteId: { type: 'string', description: 'SharePoint site ID (used for drive/file browsing)' }, + driveId: { type: 'string', description: 'Drive ID for SharePoint document libraries' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' }, cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' }, diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 0fe0d1b84bb..6b053994257 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -1504,6 +1504,7 @@ const registry: Record = { 'microsoft.excel.sheets', context.oauthCredential ?? 'none', context.spreadsheetId ?? 'none', + context.driveId ?? 'none', ], enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId), fetchList: async ({ context }: SelectorQueryArgs) => { @@ -1517,6 +1518,7 @@ const registry: Record = { searchParams: { credentialId, spreadsheetId: context.spreadsheetId, + driveId: context.driveId, workflowId: context.workflowId, }, } @@ -1527,6 +1529,54 @@ const registry: Record = { })) }, }, + 'microsoft.excel.drives': { + key: 'microsoft.excel.drives', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'microsoft.excel.drives', + context.oauthCredential ?? 'none', + context.siteId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'microsoft.excel.drives') + if (!context.siteId) { + throw new Error('Missing site ID for microsoft.excel.drives selector') + } + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + }) + const data = await fetchJson<{ drives: { id: string; name: string }[] }>( + '/api/tools/microsoft_excel/drives', + { method: 'POST', body } + ) + return (data.drives || []).map((drive) => ({ + id: drive.id, + label: drive.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId || !context.siteId) return null + const credentialId = ensureCredential(context, 'microsoft.excel.drives') + const data = await fetchJson<{ drive: { id: string; name: string } }>( + '/api/tools/microsoft_excel/drives', + { + method: 'POST', + body: JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + driveId: detailId, + }), + } + ) + if (!data.drive) return null + return { id: data.drive.id, label: data.drive.name } + }, + }, 'microsoft.excel': { key: 'microsoft.excel', staleTime: SELECTOR_STALE, @@ -1534,6 +1584,7 @@ const registry: Record = { 'selectors', 'microsoft.excel', context.oauthCredential ?? 'none', + context.driveId ?? 'none', search ?? '', ], enabled: ({ context }) => Boolean(context.oauthCredential), @@ -1545,6 +1596,7 @@ const registry: Record = { searchParams: { credentialId, query: search, + driveId: context.driveId, workflowId: context.workflowId, }, } diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index bd5bcac547b..c4423b52e33 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -40,6 +40,7 @@ export type SelectorKey = | 'onedrive.folders' | 'sharepoint.sites' | 'microsoft.excel' + | 'microsoft.excel.drives' | 'microsoft.excel.sheets' | 'microsoft.word' | 'microsoft.planner' @@ -75,6 +76,7 @@ export interface SelectorContext { siteId?: string collectionId?: string spreadsheetId?: string + driveId?: string excludeWorkflowId?: string baseId?: string datasetId?: string diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 6f41759cffa..eca39260ecc 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -17,6 +17,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'siteId', 'collectionId', 'spreadsheetId', + 'driveId', 'fileId', 'baseId', 'datasetId', diff --git a/apps/sim/tools/microsoft_excel/read.ts b/apps/sim/tools/microsoft_excel/read.ts index b1ffe5f3207..22a3aea0a39 100644 --- a/apps/sim/tools/microsoft_excel/read.ts +++ b/apps/sim/tools/microsoft_excel/read.ts @@ -6,6 +6,7 @@ import type { MicrosoftExcelV2ToolParams, } from '@/tools/microsoft_excel/types' import { + getItemBasePath, getSpreadsheetWebUrl, trimTrailingEmptyRowsAndColumns, } from '@/tools/microsoft_excel/utils' @@ -35,6 +36,13 @@ export const readTool: ToolConfig { @@ -91,6 +98,9 @@ export const readTool: ToolConfig { + const spreadsheetId = params?.spreadsheetId?.trim() || '' + const driveId = params?.driveId + // If we came from the worksheets listing (no range provided), resolve first sheet name then fetch range if (response.url.includes('/workbook/worksheets?')) { const listData = await response.json() @@ -100,23 +110,19 @@ export const readTool: ToolConfig { @@ -294,20 +289,19 @@ export const readV2Tool: ToolConfig { const data = await response.json() - const urlParts = response.url.split('/drive/items/') - const spreadsheetId = urlParts[1]?.split('/')[0] || '' + const spreadsheetId = params?.spreadsheetId?.trim() || '' + const driveId = params?.driveId const accessToken = params?.accessToken if (!accessToken) { throw new Error('Access token is required') } - const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken) + const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId) const address: string = data.address || data.addressLocal || '' const rawValues: ExcelCellValue[][] = data.values || [] const values = trimTrailingEmptyRowsAndColumns(rawValues) - // Extract sheet name from address (format: SheetName!A1:B2) const sheetName = params?.sheetName || address.split('!')[0] || '' return { diff --git a/apps/sim/tools/microsoft_excel/table_add.ts b/apps/sim/tools/microsoft_excel/table_add.ts index c84047e1146..eeca045617a 100644 --- a/apps/sim/tools/microsoft_excel/table_add.ts +++ b/apps/sim/tools/microsoft_excel/table_add.ts @@ -2,7 +2,7 @@ import type { MicrosoftExcelTableAddResponse, MicrosoftExcelTableToolParams, } from '@/tools/microsoft_excel/types' -import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils' +import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils' import type { ToolConfig } from '@/tools/types' export const tableAddTool: ToolConfig< @@ -33,6 +33,13 @@ export const tableAddTool: ToolConfig< description: 'The ID of the spreadsheet/workbook containing the table (e.g., "01ABC123DEF456")', }, + driveId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.', + }, tableName: { type: 'string', required: true, @@ -51,7 +58,8 @@ export const tableAddTool: ToolConfig< request: { url: (params) => { const tableName = encodeURIComponent(params.tableName) - return `https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/tables('${tableName}')/rows/add` + const basePath = getItemBasePath(params.spreadsheetId, params.driveId) + return `${basePath}/workbook/tables('${tableName}')/rows/add` }, method: 'POST', headers: (params) => ({ @@ -106,34 +114,26 @@ export const tableAddTool: ToolConfig< transformResponse: async (response: Response, params?: MicrosoftExcelTableToolParams) => { const data = await response.json() - const urlParts = response.url.split('/drive/items/') - const spreadsheetId = urlParts[1]?.split('/')[0] || '' + const spreadsheetId = params?.spreadsheetId?.trim() || '' + const driveId = params?.driveId - // Fetch the browser-accessible web URL const accessToken = params?.accessToken if (!accessToken) { throw new Error('Access token is required') } - const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken) + const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId) - const metadata = { - spreadsheetId, - spreadsheetUrl: webUrl, - } - - const result = { + return { success: true, output: { index: data.index || 0, values: data.values || [], metadata: { - spreadsheetId: metadata.spreadsheetId, - spreadsheetUrl: metadata.spreadsheetUrl, + spreadsheetId, + spreadsheetUrl: webUrl, }, }, } - - return result }, outputs: { diff --git a/apps/sim/tools/microsoft_excel/types.ts b/apps/sim/tools/microsoft_excel/types.ts index 1a05fafbcec..2032ce34cb5 100644 --- a/apps/sim/tools/microsoft_excel/types.ts +++ b/apps/sim/tools/microsoft_excel/types.ts @@ -63,6 +63,7 @@ export interface MicrosoftExcelWorksheetAddResponse extends ToolResponse { export interface MicrosoftExcelToolParams { accessToken: string spreadsheetId: string + driveId?: string range?: string values?: ExcelCellValue[][] valueInputOption?: 'RAW' | 'USER_ENTERED' @@ -75,6 +76,7 @@ export interface MicrosoftExcelToolParams { export interface MicrosoftExcelTableToolParams { accessToken: string spreadsheetId: string + driveId?: string tableName: string values: ExcelCellValue[][] rowIndex?: number @@ -83,6 +85,7 @@ export interface MicrosoftExcelTableToolParams { export interface MicrosoftExcelWorksheetToolParams { accessToken: string spreadsheetId: string + driveId?: string worksheetName: string } @@ -96,6 +99,7 @@ export type MicrosoftExcelResponse = export interface MicrosoftExcelV2ToolParams { accessToken: string spreadsheetId: string + driveId?: string sheetName: string cellRange?: string values?: ExcelCellValue[][] diff --git a/apps/sim/tools/microsoft_excel/utils.ts b/apps/sim/tools/microsoft_excel/utils.ts index dc10e74629d..ebb99034fca 100644 --- a/apps/sim/tools/microsoft_excel/utils.ts +++ b/apps/sim/tools/microsoft_excel/utils.ts @@ -1,8 +1,39 @@ import { createLogger } from '@sim/logger' +import { validatePathSegment } from '@/lib/core/security/input-validation' import type { ExcelCellValue } from '@/tools/microsoft_excel/types' const logger = createLogger('MicrosoftExcelUtils') +/** + * Returns the Graph API base path for an Excel item. + * When driveId is provided, uses /drives/{driveId}/items/{itemId} (SharePoint/shared drives). + * When driveId is omitted, uses /me/drive/items/{itemId} (personal OneDrive). + */ +/** Pattern for Microsoft Graph item/drive IDs: alphanumeric, hyphens, underscores, and ! (for SharePoint b! format) */ +const GRAPH_ID_PATTERN = /^[a-zA-Z0-9!_-]+$/ + +export function getItemBasePath(spreadsheetId: string, driveId?: string): string { + const spreadsheetValidation = validatePathSegment(spreadsheetId, { + paramName: 'spreadsheetId', + customPattern: GRAPH_ID_PATTERN, + }) + if (!spreadsheetValidation.isValid) { + throw new Error(spreadsheetValidation.error) + } + + if (driveId) { + const driveValidation = validatePathSegment(driveId, { + paramName: 'driveId', + customPattern: GRAPH_ID_PATTERN, + }) + if (!driveValidation.isValid) { + throw new Error(driveValidation.error) + } + return `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${spreadsheetId}` + } + return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}` +} + export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): ExcelCellValue[][] { if (!Array.isArray(matrix) || matrix.length === 0) return [] @@ -43,33 +74,32 @@ export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): Exc */ export async function getSpreadsheetWebUrl( spreadsheetId: string, - accessToken: string + accessToken: string, + driveId?: string ): Promise { + const basePath = getItemBasePath(spreadsheetId, driveId) try { - const response = await fetch( - `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}?$select=id,webUrl`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) + const response = await fetch(`${basePath}?$select=id,webUrl`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) if (!response.ok) { logger.warn('Failed to fetch spreadsheet webUrl, using Graph API URL as fallback', { spreadsheetId, status: response.status, }) - return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}` + return basePath } const data = await response.json() - return data.webUrl || `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}` + return data.webUrl || basePath } catch (error) { logger.warn('Error fetching spreadsheet webUrl, using Graph API URL as fallback', { spreadsheetId, error, }) - return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}` + return basePath } } diff --git a/apps/sim/tools/microsoft_excel/worksheet_add.ts b/apps/sim/tools/microsoft_excel/worksheet_add.ts index 1350bc55532..6cfc91b47db 100644 --- a/apps/sim/tools/microsoft_excel/worksheet_add.ts +++ b/apps/sim/tools/microsoft_excel/worksheet_add.ts @@ -2,7 +2,7 @@ import type { MicrosoftExcelWorksheetAddResponse, MicrosoftExcelWorksheetToolParams, } from '@/tools/microsoft_excel/types' -import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils' +import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils' import type { ToolConfig } from '@/tools/types' /** @@ -36,6 +36,13 @@ export const worksheetAddTool: ToolConfig< visibility: 'user-or-llm', description: 'The ID of the Excel workbook to add the worksheet to (e.g., "01ABC123DEF456")', }, + driveId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.', + }, worksheetName: { type: 'string', required: true, @@ -51,7 +58,8 @@ export const worksheetAddTool: ToolConfig< if (!spreadsheetId) { throw new Error('Spreadsheet ID is required') } - return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets/add` + const basePath = getItemBasePath(spreadsheetId, params.driveId) + return `${basePath}/workbook/worksheets/add` }, method: 'POST', headers: (params) => { @@ -106,15 +114,14 @@ export const worksheetAddTool: ToolConfig< const data = await response.json() - const urlParts = response.url.split('/drive/items/') - const spreadsheetId = urlParts[1]?.split('/')[0] || '' + const spreadsheetId = params?.spreadsheetId?.trim() || '' + const driveId = params?.driveId - // Fetch the browser-accessible web URL const accessToken = params?.accessToken if (!accessToken) { throw new Error('Access token is required') } - const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken) + const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId) const result: MicrosoftExcelWorksheetAddResponse = { success: true, diff --git a/apps/sim/tools/microsoft_excel/write.ts b/apps/sim/tools/microsoft_excel/write.ts index 335d59acc6c..1e5fa7b3bde 100644 --- a/apps/sim/tools/microsoft_excel/write.ts +++ b/apps/sim/tools/microsoft_excel/write.ts @@ -4,7 +4,7 @@ import type { MicrosoftExcelV2WriteResponse, MicrosoftExcelWriteResponse, } from '@/tools/microsoft_excel/types' -import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils' +import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils' import type { ToolConfig } from '@/tools/types' export const writeTool: ToolConfig = { @@ -31,6 +31,13 @@ export const writeTool: ToolConfig { const data = await response.json() - const urlParts = response.url.split('/drive/items/') - const spreadsheetId = urlParts[1]?.split('/')[0] || '' + const spreadsheetId = params?.spreadsheetId?.trim() || '' + const driveId = params?.driveId - // Fetch the browser-accessible web URL const accessToken = params?.accessToken if (!accessToken) { throw new Error('Access token is required') } - const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken) - - const metadata = { - spreadsheetId, - properties: {}, - spreadsheetUrl: webUrl, - } + const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId) - const result = { + return { success: true, output: { updatedRange: data.updatedRange, @@ -161,13 +162,11 @@ export const writeTool: ToolConfig { const data = await response.json() - const urlParts = response.url.split('/drive/items/') - const spreadsheetId = urlParts[1]?.split('/')[0] || '' + const spreadsheetId = params?.spreadsheetId?.trim() || '' + const driveId = params?.driveId const accessToken = params?.accessToken if (!accessToken) { throw new Error('Access token is required') } - const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken) + const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId) return { success: true,