d25d482dc2
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
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
})
|