From 80095788fc2a900626ef8064060ceb84cb4b9e31 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 14 Apr 2026 21:10:55 -0700 Subject: [PATCH] feat(microsoft-excel): add SharePoint drive support for Excel integration (#4162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(microsoft-excel): add SharePoint drive support for Excel integration * fix(microsoft-excel): address PR review comments - Validate siteId/driveId format in drives route to prevent path traversal - Use direct single-drive endpoint for fetchById instead of filtering full list - Fix dependsOn on sheet/spreadsheet selectors so driveId flows into context - Fix NextRequest type in drives route for build compatibility * fix(microsoft-excel): validate driveId in files route Add regex validation for driveId query param in the Microsoft OAuth files route to prevent path traversal, matching the drives route. * fix(microsoft-excel): unblock OneDrive users and validate driveId in sheets route - Add credential to any[] arrays so OneDrive users (no drive selected) still pass the dependsOn gate while driveSelector remains in the dependency list for context flow to SharePoint users - Add /^[\w-]+$/ validation for driveId in sheets API route * fix(microsoft-excel): validate driveId in getItemBasePath utility Add regex validation for driveId at the shared utility level to prevent path traversal through the tool execution path, which bypasses the API route validators. * fix(microsoft-excel): use centralized input validation Replace inline regex validation with platform validators from @/lib/core/security/input-validation: - validateSharePointSiteId for siteId in drives route - validateAlphanumericId for driveId in drives, sheets, files routes and getItemBasePath utility * lint * improvement(microsoft-excel): add File Source dropdown to control SharePoint visibility Replace always-visible optional SharePoint fields with a File Source dropdown (OneDrive/SharePoint) that conditionally shows site and drive selectors. OneDrive users see zero extra fields (default). SharePoint users switch the dropdown and get the full cascade. * fix(microsoft-excel): fix canonical param test failures Make fileSource dropdown mode:'both' so it appears in basic and advanced modes. Add condition to manualDriveId to match driveSelector's condition, satisfying the canonical pair consistency test. * fix(microsoft-excel): address PR review feedback for SharePoint drive support - Clear stale driveId/siteId/spreadsheetId when fileSource changes by adding fileSource to dependsOn arrays for siteSelector, driveSelector, and spreadsheetId selectors - Reorder manualDriveId before manualSpreadsheetId in advanced mode for logical top-down flow - Validate spreadsheetId with validateMicrosoftGraphId in getItemBasePath() and sheets route to close injection vector (uses permissive validator that accepts ! chars in OneDrive item IDs) Co-Authored-By: Claude Opus 4.6 * fix(microsoft-excel): use validateMicrosoftGraphId for driveId validation SharePoint drive IDs use the format b! which contains ! characters rejected by validateAlphanumericId. Switch all driveId validation to validateMicrosoftGraphId which blocks path traversal and control characters while accepting valid Microsoft Graph identifiers. Co-Authored-By: Claude Opus 4.6 * fix(microsoft-excel): use validatePathSegment with strict pattern for driveId/spreadsheetId Replace validateMicrosoftGraphId with validatePathSegment using a custom pattern ^[a-zA-Z0-9!_-]+$ for all URL-interpolated IDs. validatePathSegment blocks /, \, path traversal, and null bytes before checking the pattern, preventing URL-modifying characters like ?, #, & from altering the Graph API endpoint. The pattern allows ! for SharePoint b! drive IDs. Co-Authored-By: Claude Opus 4.6 * lint * fix(microsoft-excel): reorder driveId before spreadsheetId in v1 block Move driveId subBlock before manualSpreadsheetId in the legacy v1 block to match the logical top-down flow (Drive ID → Spreadsheet ID), consistent with the v2 block ordering. Co-Authored-By: Claude Opus 4.6 * fix(microsoft-excel): clear manualDriveId when fileSource changes Add dependsOn: ['fileSource'] to manualDriveId so its value is cleared when switching from SharePoint back to OneDrive. Without this, the stale driveId would still be serialized and forwarded to getItemBasePath, routing through the SharePoint drive path instead of me/drive. Co-Authored-By: Claude Opus 4.6 * refactor(microsoft-excel): use getItemBasePath in sheets route to remove duplication Replace inline URL construction and validation logic with the shared getItemBasePath utility, eliminating duplicated GRAPH_ID_PATTERN regex and conditional URL building. Co-Authored-By: Claude Opus 4.6 * lint --------- Co-authored-by: Claude Opus 4.6 --- .../content/docs/en/tools/microsoft_excel.mdx | 2 + .../api/auth/oauth/microsoft/files/route.ts | 17 ++- .../api/tools/microsoft_excel/drives/route.ts | 134 ++++++++++++++++++ .../api/tools/microsoft_excel/sheets/route.ts | 30 ++-- apps/sim/blocks/blocks/microsoft_excel.ts | 104 ++++++++++++-- apps/sim/hooks/selectors/registry.ts | 52 +++++++ apps/sim/hooks/selectors/types.ts | 2 + apps/sim/lib/workflows/subblocks/context.ts | 1 + apps/sim/tools/microsoft_excel/read.ts | 80 +++++------ apps/sim/tools/microsoft_excel/table_add.ts | 32 ++--- apps/sim/tools/microsoft_excel/types.ts | 4 + apps/sim/tools/microsoft_excel/utils.ts | 54 +++++-- .../tools/microsoft_excel/worksheet_add.ts | 19 ++- apps/sim/tools/microsoft_excel/write.ts | 49 ++++--- 14 files changed, 462 insertions(+), 118 deletions(-) create mode 100644 apps/sim/app/api/tools/microsoft_excel/drives/route.ts diff --git a/apps/docs/content/docs/en/tools/microsoft_excel.mdx b/apps/docs/content/docs/en/tools/microsoft_excel.mdx index 4da61a5b9c..08733e5eb3 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 23bd2e57e5..a6e6add80f 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 0000000000..d9c9aa845b --- /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 d4f8035149..367e04fc41 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 4b04368742..8106d23425 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 0fe0d1b84b..6b05399425 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 bd5bcac547..c4423b52e3 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 6f41759cff..eca39260ec 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 b1ffe5f320..22a3aea0a3 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 c84047e114..eeca045617 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 1a05fafbce..2032ce34cb 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 dc10e74629..ebb99034fc 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 1350bc5553..6cfc91b47d 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 335d59acc6..1e5fa7b3bd 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 webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId) - const metadata = { - spreadsheetId, - properties: {}, - spreadsheetUrl: webUrl, - } - - 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,