chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
createMockRequest,
|
||||
hybridAuthMockFns,
|
||||
inputValidationMock,
|
||||
inputValidationMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
|
||||
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import { POST } from '@/app/api/tools/onedrive/download/route'
|
||||
|
||||
const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
|
||||
|
||||
const PINNED_IP = '93.184.216.34'
|
||||
|
||||
const baseBody = {
|
||||
accessToken: 'token-123',
|
||||
fileId: 'file-abc',
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, ok = true) {
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 400,
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}
|
||||
}
|
||||
|
||||
function fileResponse(bytes: number) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
text: async () => '',
|
||||
json: async () => ({}),
|
||||
arrayBuffer: async () => new ArrayBuffer(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: PINNED_IP,
|
||||
originalHostname: 'graph.microsoft.com',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/tools/onedrive/download', () => {
|
||||
it('downloads a normal file under the size cap', async () => {
|
||||
mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ id: 'file-abc', name: 'report.pdf', file: { mimeType: 'application/pdf' } })
|
||||
)
|
||||
.mockResolvedValueOnce(fileResponse(1024))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.file.size).toBe(1024)
|
||||
|
||||
const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
|
||||
})
|
||||
|
||||
it('surfaces a clean 413 when the streamed content exceeds the cap', async () => {
|
||||
mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: 'file-abc',
|
||||
name: 'huge.bin',
|
||||
file: { mimeType: 'application/octet-stream' },
|
||||
})
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new PayloadSizeLimitError({
|
||||
label: 'response body',
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
observedBytes: MAX_FILE_SIZE + 1,
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(413)
|
||||
const data = (await response.json()) as { success: boolean }
|
||||
expect(data.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { onedriveDownloadContract } from '@/lib/api/contracts/tools/microsoft'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Microsoft Graph API error response structure */
|
||||
interface GraphApiError {
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Microsoft Graph API drive item metadata response */
|
||||
interface DriveItemMetadata {
|
||||
id?: string
|
||||
name?: string
|
||||
folder?: Record<string, unknown>
|
||||
file?: {
|
||||
mimeType?: string
|
||||
}
|
||||
}
|
||||
|
||||
const logger = createLogger('OneDriveDownloadAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized OneDrive download attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(onedriveDownloadContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, fileId, fileName } = parsed.data.body
|
||||
const authHeader = `Bearer ${accessToken}`
|
||||
|
||||
logger.info(`[${requestId}] Getting file metadata from OneDrive`, { fileId })
|
||||
|
||||
const metadataUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}`
|
||||
const metadataUrlValidation = await validateUrlWithDNS(metadataUrl, 'metadataUrl')
|
||||
if (!metadataUrlValidation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: metadataUrlValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const metadataResponse = await secureFetchWithPinnedIP(
|
||||
metadataUrl,
|
||||
metadataUrlValidation.resolvedIP!,
|
||||
{
|
||||
headers: { Authorization: authHeader },
|
||||
}
|
||||
)
|
||||
|
||||
if (!metadataResponse.ok) {
|
||||
const errorDetails = (await metadataResponse.json().catch(() => ({}))) as GraphApiError
|
||||
logger.error(`[${requestId}] Failed to get file metadata`, {
|
||||
status: metadataResponse.status,
|
||||
error: errorDetails,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorDetails.error?.message || 'Failed to get file metadata' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const metadata = (await metadataResponse.json()) as DriveItemMetadata
|
||||
|
||||
if (metadata.folder && !metadata.file) {
|
||||
logger.error(`[${requestId}] Attempted to download a folder`, {
|
||||
itemId: metadata.id,
|
||||
itemName: metadata.name,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Cannot download folder "${metadata.name}". Please select a file instead.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const mimeType = metadata.file?.mimeType || 'application/octet-stream'
|
||||
|
||||
logger.info(`[${requestId}] Downloading file from OneDrive`, { fileId, mimeType })
|
||||
|
||||
const downloadUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}/content`
|
||||
const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl')
|
||||
if (!downloadUrlValidation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: downloadUrlValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const downloadResponse = await secureFetchWithPinnedIP(
|
||||
downloadUrl,
|
||||
downloadUrlValidation.resolvedIP!,
|
||||
{
|
||||
headers: { Authorization: authHeader },
|
||||
maxResponseBytes: MAX_FILE_SIZE,
|
||||
}
|
||||
)
|
||||
|
||||
if (!downloadResponse.ok) {
|
||||
const downloadError = (await downloadResponse.json().catch(() => ({}))) as GraphApiError
|
||||
logger.error(`[${requestId}] Failed to download file`, {
|
||||
status: downloadResponse.status,
|
||||
error: downloadError,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: downloadError.error?.message || 'Failed to download file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const arrayBuffer = await downloadResponse.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const resolvedName = fileName || metadata.name || 'download'
|
||||
|
||||
logger.info(`[${requestId}] File downloaded successfully`, {
|
||||
fileId,
|
||||
name: resolvedName,
|
||||
size: fileBuffer.length,
|
||||
mimeType,
|
||||
})
|
||||
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: resolvedName,
|
||||
mimeType,
|
||||
data: base64Data,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error downloading OneDrive file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: isPayloadSizeLimitError(error) ? 413 : 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { onedriveFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
|
||||
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('OneDriveFilesAPI')
|
||||
|
||||
/**
|
||||
* Microsoft Graph paginates drive item collections via the `@odata.nextLink`
|
||||
* absolute URL in the response body. Request the largest page (`$top` caps at
|
||||
* 999) and drain following nextLink, bounded by a page cap.
|
||||
* See https://learn.microsoft.com/en-us/graph/paging
|
||||
*/
|
||||
const ONEDRIVE_FILES_PAGE_SIZE = 999
|
||||
const MAX_ONEDRIVE_FILES_PAGES = 20
|
||||
|
||||
/**
|
||||
* Get files (not folders) from Microsoft OneDrive
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
logger.info(`[${requestId}] OneDrive files request received`)
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validation = onedriveFilesQuerySchema.safeParse({
|
||||
credentialId: searchParams.get('credentialId') ?? '',
|
||||
query: searchParams.get('query') ?? undefined,
|
||||
})
|
||||
if (!validation.success) {
|
||||
logger.warn(`[${requestId}] Invalid files request data`, { errors: validation.error.issues })
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(validation.error, 'Invalid request') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { credentialId } = validation.data
|
||||
const query = validation.data.query ?? ''
|
||||
|
||||
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetching credential`, { credentialId })
|
||||
|
||||
const credAccess = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
if (!credAccess.ok || !credAccess.credentialOwnerUserId) {
|
||||
logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error })
|
||||
return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
credAccess.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// $filter is unsupported on the /children endpoint; use search when a query is present
|
||||
let url: string
|
||||
if (query) {
|
||||
const searchParams_new = new URLSearchParams()
|
||||
searchParams_new.append(
|
||||
'$select',
|
||||
'id,name,file,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,thumbnails'
|
||||
)
|
||||
searchParams_new.append('$top', String(ONEDRIVE_FILES_PAGE_SIZE))
|
||||
url = `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(query)}')?${searchParams_new.toString()}`
|
||||
} else {
|
||||
const searchParams_new = new URLSearchParams()
|
||||
searchParams_new.append(
|
||||
'$select',
|
||||
'id,name,file,folder,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,thumbnails'
|
||||
)
|
||||
searchParams_new.append('$top', String(ONEDRIVE_FILES_PAGE_SIZE))
|
||||
url = `https://graph.microsoft.com/v1.0/me/drive/root/children?${searchParams_new.toString()}`
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetching files from Microsoft Graph`, { url })
|
||||
|
||||
const rawItems: MicrosoftGraphDriveItem[] = []
|
||||
let nextUrl: string | undefined = url
|
||||
|
||||
for (let page = 0; page < MAX_ONEDRIVE_FILES_PAGES && nextUrl; page++) {
|
||||
const response = await fetch(nextUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
logger.error(`[${requestId}] Microsoft Graph API error`, {
|
||||
status: response.status,
|
||||
error: errorData.error?.message || 'Failed to fetch files from OneDrive',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || 'Failed to fetch files from OneDrive' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
rawItems.push(...((data.value as MicrosoftGraphDriveItem[]) || []))
|
||||
|
||||
const nextLink = getGraphNextPageUrl(data)
|
||||
nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined
|
||||
|
||||
if (nextUrl && page === MAX_ONEDRIVE_FILES_PAGES - 1) {
|
||||
logger.warn(`[${requestId}] OneDrive files hit pagination cap; list may be incomplete`, {
|
||||
pages: MAX_ONEDRIVE_FILES_PAGES,
|
||||
collected: rawItems.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Received ${rawItems.length} items from Microsoft Graph`)
|
||||
|
||||
const files = rawItems
|
||||
.filter((item: MicrosoftGraphDriveItem) => !!item.file && !item.folder)
|
||||
.map((file: MicrosoftGraphDriveItem) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimeType: file.file?.mimeType || 'application/octet-stream',
|
||||
iconLink: file.thumbnails?.[0]?.small?.url,
|
||||
webViewLink: file.webUrl,
|
||||
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
|
||||
createdTime: file.createdDateTime,
|
||||
modifiedTime: file.lastModifiedDateTime,
|
||||
size: file.size?.toString(),
|
||||
owners: file.createdBy
|
||||
? [
|
||||
{
|
||||
displayName: file.createdBy.user?.displayName || 'Unknown',
|
||||
emailAddress: file.createdBy.user?.email || '',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Returning ${files.length} files`, {
|
||||
totalItems: rawItems.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ files }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching files from OneDrive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { onedriveFolderQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('OneDriveFolderAPI')
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validation = onedriveFolderQuerySchema.safeParse({
|
||||
credentialId: searchParams.get('credentialId') ?? '',
|
||||
fileId: searchParams.get('fileId') ?? '',
|
||||
})
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(validation.error, 'Invalid request') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { credentialId, fileId } = validation.data
|
||||
|
||||
const fileIdValidation = validateMicrosoftGraphId(fileId, 'fileId')
|
||||
if (!fileIdValidation.isValid) {
|
||||
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credAccess = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
if (!credAccess.ok || !credAccess.credentialOwnerUserId) {
|
||||
logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error })
|
||||
return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
credAccess.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime`,
|
||||
{
|
||||
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 folder from OneDrive' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const folder = await response.json()
|
||||
|
||||
const transformedFolder = {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
webViewLink: folder.webUrl,
|
||||
createdTime: folder.createdDateTime,
|
||||
modifiedTime: folder.lastModifiedDateTime,
|
||||
}
|
||||
|
||||
return NextResponse.json({ file: transformedFolder }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching folder from OneDrive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { onedriveFoldersQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
|
||||
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('OneDriveFoldersAPI')
|
||||
|
||||
/**
|
||||
* Microsoft Graph paginates drive item collections via the `@odata.nextLink`
|
||||
* absolute URL in the response body. Request the largest page (`$top` caps at
|
||||
* 999) and drain following nextLink, bounded by a page cap.
|
||||
* See https://learn.microsoft.com/en-us/graph/paging
|
||||
*/
|
||||
const ONEDRIVE_FOLDERS_PAGE_SIZE = 999
|
||||
const MAX_ONEDRIVE_FOLDERS_PAGES = 20
|
||||
|
||||
/**
|
||||
* Get folders from Microsoft OneDrive
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validation = onedriveFoldersQuerySchema.safeParse({
|
||||
credentialId: searchParams.get('credentialId') ?? '',
|
||||
query: searchParams.get('query') ?? undefined,
|
||||
})
|
||||
if (!validation.success) {
|
||||
logger.warn(`[${requestId}] Invalid folders request data`, {
|
||||
errors: validation.error.issues,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(validation.error, 'Invalid request') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { credentialId } = validation.data
|
||||
const query = validation.data.query ?? ''
|
||||
|
||||
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=${ONEDRIVE_FOLDERS_PAGE_SIZE}`
|
||||
|
||||
if (query) {
|
||||
url += `&$search="${encodeURIComponent(query)}"`
|
||||
}
|
||||
|
||||
const rawItems: MicrosoftGraphDriveItem[] = []
|
||||
let nextUrl: string | undefined = url
|
||||
|
||||
for (let page = 0; page < MAX_ONEDRIVE_FOLDERS_PAGES && nextUrl; page++) {
|
||||
const response = await fetch(nextUrl, {
|
||||
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 folders from OneDrive' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
rawItems.push(...((data.value as MicrosoftGraphDriveItem[]) || []))
|
||||
|
||||
const nextLink = getGraphNextPageUrl(data)
|
||||
nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined
|
||||
|
||||
if (nextUrl && page === MAX_ONEDRIVE_FOLDERS_PAGES - 1) {
|
||||
logger.warn(`[${requestId}] OneDrive folders hit pagination cap; list may be incomplete`, {
|
||||
pages: MAX_ONEDRIVE_FOLDERS_PAGES,
|
||||
collected: rawItems.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const folders = rawItems
|
||||
.filter((item: MicrosoftGraphDriveItem) => item.folder)
|
||||
.map((folder: MicrosoftGraphDriveItem) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
webViewLink: folder.webUrl,
|
||||
createdTime: folder.createdDateTime,
|
||||
modifiedTime: folder.lastModifiedDateTime,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ files: folders }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching folders from OneDrive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { onedriveUploadContract } from '@/lib/api/contracts/tools/microsoft'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
getExtensionFromMimeType,
|
||||
processSingleFileToUserFile,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
|
||||
import { assertToolFileAccess } from '@/app/api/files/authorization'
|
||||
import { normalizeExcelValues } from '@/tools/onedrive/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('OneDriveUploadAPI')
|
||||
|
||||
const MICROSOFT_GRAPH_BASE = 'https://graph.microsoft.com/v1.0'
|
||||
|
||||
/** Microsoft Graph DriveItem response */
|
||||
interface OneDriveFileData {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
webUrl: string
|
||||
createdDateTime: string
|
||||
lastModifiedDateTime: string
|
||||
file?: { mimeType: string }
|
||||
parentReference?: { id: string; path: string }
|
||||
'@microsoft.graph.downloadUrl'?: string
|
||||
}
|
||||
|
||||
/** Microsoft Graph Excel range response */
|
||||
interface ExcelRangeData {
|
||||
address?: string
|
||||
addressLocal?: string
|
||||
values?: unknown[][]
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized OneDrive upload attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated OneDrive upload request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const parsed = await parseRequest(onedriveUploadContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
const excelValues = normalizeExcelValues(validatedData.values)
|
||||
|
||||
let fileBuffer: Buffer
|
||||
let mimeType: string
|
||||
|
||||
const isExcelCreation =
|
||||
validatedData.mimeType ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' && !validatedData.file
|
||||
|
||||
if (isExcelCreation) {
|
||||
const workbook = XLSX.utils.book_new()
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([[]])
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
|
||||
|
||||
const xlsxBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
|
||||
fileBuffer = Buffer.from(xlsxBuffer)
|
||||
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
} else {
|
||||
const rawFile = validatedData.file
|
||||
|
||||
if (!rawFile) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No file provided',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let userFile
|
||||
try {
|
||||
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to process file'),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
|
||||
if (denied) return denied
|
||||
|
||||
try {
|
||||
const result = await downloadServableFileFromStorage(userFile, requestId, logger)
|
||||
fileBuffer = result.buffer
|
||||
mimeType = result.contentType || userFile.type || 'application/octet-stream'
|
||||
} catch (error) {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
logger.error(`[${requestId}] Failed to download file from storage:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const maxSize = 250 * 1024 * 1024
|
||||
if (fileBuffer.length > maxSize) {
|
||||
const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2)
|
||||
logger.warn(`[${requestId}] File too large: ${sizeMB}MB`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `File size (${sizeMB}MB) exceeds OneDrive's limit of 250MB for simple uploads. Use chunked upload for larger files.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let fileName = validatedData.fileName
|
||||
const hasExtension = fileName.includes('.') && fileName.lastIndexOf('.') > 0
|
||||
|
||||
if (!hasExtension) {
|
||||
const extension = getExtensionFromMimeType(mimeType)
|
||||
if (extension) {
|
||||
fileName = `${fileName}.${extension}`
|
||||
logger.info(`[${requestId}] Added extension to filename: ${fileName}`)
|
||||
}
|
||||
} else if (isExcelCreation && !fileName.endsWith('.xlsx')) {
|
||||
fileName = `${fileName.replace(/\.[^.]*$/, '')}.xlsx`
|
||||
}
|
||||
|
||||
let uploadUrl: string
|
||||
const folderId = validatedData.folderId?.trim()
|
||||
|
||||
if (folderId && folderId !== '') {
|
||||
const folderIdValidation = validateMicrosoftGraphId(folderId, 'folderId')
|
||||
if (!folderIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid folder ID`, { error: folderIdValidation.error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: folderIdValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content`
|
||||
} else {
|
||||
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content`
|
||||
}
|
||||
|
||||
// Add conflict behavior if specified (defaults to replace by Microsoft Graph API)
|
||||
if (validatedData.conflictBehavior) {
|
||||
uploadUrl += `?@microsoft.graph.conflictBehavior=${validatedData.conflictBehavior}`
|
||||
}
|
||||
|
||||
const uploadResponse = await secureFetchWithValidation(
|
||||
uploadUrl,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
body: fileBuffer,
|
||||
},
|
||||
'uploadUrl'
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorText = await uploadResponse.text()
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `OneDrive upload failed: ${uploadResponse.statusText}`,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: uploadResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const fileData = (await uploadResponse.json()) as OneDriveFileData
|
||||
|
||||
let excelWriteResult: any | undefined
|
||||
const shouldWriteExcelContent =
|
||||
isExcelCreation && Array.isArray(excelValues) && excelValues.length > 0
|
||||
|
||||
if (shouldWriteExcelContent) {
|
||||
try {
|
||||
let workbookSessionId: string | undefined
|
||||
const sessionUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
fileData.id
|
||||
)}/workbook/createSession`
|
||||
const sessionResp = await secureFetchWithValidation(
|
||||
sessionUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ persistChanges: true }),
|
||||
},
|
||||
'sessionUrl'
|
||||
)
|
||||
|
||||
if (sessionResp.ok) {
|
||||
const sessionData = (await sessionResp.json()) as { id?: string }
|
||||
workbookSessionId = sessionData?.id
|
||||
}
|
||||
|
||||
let sheetName = 'Sheet1'
|
||||
try {
|
||||
const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
fileData.id
|
||||
)}/workbook/worksheets?$select=name&$orderby=position&$top=1`
|
||||
const listResp = await secureFetchWithValidation(
|
||||
listUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
|
||||
},
|
||||
},
|
||||
'listUrl'
|
||||
)
|
||||
if (listResp.ok) {
|
||||
const listData = (await listResp.json()) as { value?: Array<{ name?: string }> }
|
||||
const firstSheetName = listData?.value?.[0]?.name
|
||||
if (firstSheetName) {
|
||||
sheetName = firstSheetName
|
||||
}
|
||||
} else {
|
||||
const listErr = await listResp.text()
|
||||
logger.warn(`[${requestId}] Failed to list worksheets, using default Sheet1`, {
|
||||
status: listResp.status,
|
||||
error: listErr,
|
||||
})
|
||||
}
|
||||
} catch (listError) {
|
||||
logger.warn(`[${requestId}] Error listing worksheets, using default Sheet1`, listError)
|
||||
}
|
||||
|
||||
let processedValues: any = excelValues || []
|
||||
|
||||
if (
|
||||
Array.isArray(processedValues) &&
|
||||
processedValues.length > 0 &&
|
||||
typeof processedValues[0] === 'object' &&
|
||||
!Array.isArray(processedValues[0])
|
||||
) {
|
||||
const ws = XLSX.utils.json_to_sheet(processedValues)
|
||||
processedValues = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
||||
}
|
||||
|
||||
const rowsCount = processedValues.length
|
||||
const colsCount = Math.max(...processedValues.map((row: any[]) => row.length), 0)
|
||||
processedValues = processedValues.map((row: any[]) => {
|
||||
const paddedRow = [...row]
|
||||
while (paddedRow.length < colsCount) paddedRow.push('')
|
||||
return paddedRow
|
||||
})
|
||||
|
||||
const indexToColLetters = (index: number): string => {
|
||||
let n = index
|
||||
let s = ''
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26
|
||||
s = String.fromCharCode(65 + rem) + s
|
||||
n = Math.floor((n - 1) / 26)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
const endColLetters = colsCount > 0 ? indexToColLetters(colsCount) : 'A'
|
||||
const endRow = rowsCount > 0 ? rowsCount : 1
|
||||
const computedRangeAddress = `A1:${endColLetters}${endRow}`
|
||||
|
||||
const url = new URL(
|
||||
`${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
fileData.id
|
||||
)}/workbook/worksheets('${encodeURIComponent(
|
||||
sheetName
|
||||
)}')/range(address='${encodeURIComponent(computedRangeAddress)}')`
|
||||
)
|
||||
|
||||
const excelWriteResponse = await secureFetchWithValidation(
|
||||
url.toString(),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}),
|
||||
},
|
||||
body: JSON.stringify({ values: processedValues }),
|
||||
},
|
||||
'excelWriteUrl'
|
||||
)
|
||||
|
||||
if (!excelWriteResponse || !excelWriteResponse.ok) {
|
||||
const errorText = excelWriteResponse ? await excelWriteResponse.text() : 'no response'
|
||||
logger.error(`[${requestId}] Excel content write failed`, {
|
||||
status: excelWriteResponse?.status,
|
||||
statusText: excelWriteResponse?.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
excelWriteResult = {
|
||||
success: false,
|
||||
error: `Excel write failed: ${excelWriteResponse?.statusText || 'unknown'}`,
|
||||
details: errorText,
|
||||
}
|
||||
} else {
|
||||
const writeData = (await excelWriteResponse.json()) as ExcelRangeData
|
||||
const addr = writeData.address || writeData.addressLocal
|
||||
const v = writeData.values || []
|
||||
excelWriteResult = {
|
||||
success: true,
|
||||
updatedRange: addr,
|
||||
updatedRows: Array.isArray(v) ? v.length : undefined,
|
||||
updatedColumns: Array.isArray(v) && v[0] ? v[0].length : undefined,
|
||||
updatedCells: Array.isArray(v) && v[0] ? v.length * v[0].length : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (workbookSessionId) {
|
||||
try {
|
||||
const closeUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
|
||||
fileData.id
|
||||
)}/workbook/closeSession`
|
||||
const closeResp = await secureFetchWithValidation(
|
||||
closeUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'workbook-session-id': workbookSessionId,
|
||||
},
|
||||
},
|
||||
'closeSessionUrl'
|
||||
)
|
||||
if (!closeResp.ok) {
|
||||
const closeText = await closeResp.text()
|
||||
logger.warn(`[${requestId}] Failed to close Excel session`, {
|
||||
status: closeResp.status,
|
||||
error: closeText,
|
||||
})
|
||||
}
|
||||
} catch (closeErr) {
|
||||
logger.warn(`[${requestId}] Error closing Excel session`, closeErr)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[${requestId}] Exception during Excel content write`, err)
|
||||
excelWriteResult = {
|
||||
success: false,
|
||||
error: getErrorMessage(err, 'Unknown error during Excel write'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
id: fileData.id,
|
||||
name: fileData.name,
|
||||
mimeType: fileData.file?.mimeType || mimeType,
|
||||
webViewLink: fileData.webUrl,
|
||||
webContentLink: fileData['@microsoft.graph.downloadUrl'],
|
||||
size: fileData.size,
|
||||
createdTime: fileData.createdDateTime,
|
||||
modifiedTime: fileData.lastModifiedDateTime,
|
||||
parentReference: fileData.parentReference,
|
||||
},
|
||||
...(excelWriteResult ? { excelWriteResult } : {}),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error uploading file to OneDrive:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Internal server error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user