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,178 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sharepointDownloadFileContract } 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 { 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('SharepointDownloadFileAPI')
|
||||
|
||||
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 SharePoint download attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(sharepointDownloadFileContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, driveId, itemId, fileName } = parsed.data.body
|
||||
const authHeader = `Bearer ${accessToken}`
|
||||
|
||||
logger.info(`[${requestId}] Getting file metadata from SharePoint`, { driveId, itemId })
|
||||
|
||||
const metadataUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}`
|
||||
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 SharePoint`, { driveId, itemId, mimeType })
|
||||
|
||||
const downloadUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/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 },
|
||||
// The content endpoint 302s to a preauthenticated URL on a different origin that needs no auth.
|
||||
stripAuthOnRedirect: true,
|
||||
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`, {
|
||||
driveId,
|
||||
itemId,
|
||||
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 SharePoint file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sharepointListsSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateSharePointSiteId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SharePointListsAPI')
|
||||
|
||||
/**
|
||||
* Upper bound on Microsoft Graph pages drained when listing SharePoint lists.
|
||||
* Each page returns up to `$top=999` lists, so this caps the result set at
|
||||
* roughly 10k lists while preventing an unbounded server-side loop.
|
||||
*/
|
||||
const MAX_LISTS_PAGES = 10
|
||||
|
||||
interface SharePointList {
|
||||
id: string
|
||||
displayName: string
|
||||
description?: string
|
||||
webUrl?: string
|
||||
list?: {
|
||||
hidden?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(sharepointListsSelectorContract, request, {})
|
||||
if (!parsed.success) {
|
||||
logger.warn(`[${requestId}] Invalid lists request data`)
|
||||
return parsed.response
|
||||
}
|
||||
const { credential, workflowId, siteId } = parsed.data.body
|
||||
|
||||
const siteIdValidation = validateSharePointSiteId(siteId)
|
||||
if (!siteIdValidation.isValid) {
|
||||
logger.error(`[${requestId}] Invalid siteId: ${siteIdValidation.error}`)
|
||||
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.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to obtain valid access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
let nextUrl: string | undefined =
|
||||
`https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=999`
|
||||
|
||||
const rawLists: SharePointList[] = []
|
||||
for (let page = 0; page < MAX_LISTS_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 lists from SharePoint' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (Array.isArray(data.value)) {
|
||||
rawLists.push(...data.value)
|
||||
}
|
||||
|
||||
const nextLink = getGraphNextPageUrl(data)
|
||||
nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined
|
||||
if (nextUrl && page === MAX_LISTS_PAGES - 1) {
|
||||
logger.warn(
|
||||
`[${requestId}] SharePoint lists pagination hit ${MAX_LISTS_PAGES}-page cap; result may be incomplete`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const lists = rawLists
|
||||
.filter((list: SharePointList) => list.list?.hidden !== true)
|
||||
.map((list: SharePointList) => ({
|
||||
id: list.id,
|
||||
displayName: list.displayName,
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Successfully fetched ${lists.length} SharePoint lists`)
|
||||
return NextResponse.json({ lists }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching lists from SharePoint`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sharepointSiteQuerySchema } from '@/lib/api/contracts/selectors/sharepoint'
|
||||
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('SharePointSiteAPI')
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validation = sharepointSiteQuerySchema.safeParse({
|
||||
credentialId: searchParams.get('credentialId') ?? '',
|
||||
siteId: searchParams.get('siteId') ?? '',
|
||||
})
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(validation.error, 'Invalid request') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { credentialId, siteId } = validation.data
|
||||
|
||||
const siteIdValidation = validateMicrosoftGraphId(siteId, 'siteId')
|
||||
if (!siteIdValidation.isValid) {
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request, { credentialId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
authz.resolvedCredentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
let endpoint: string
|
||||
if (siteId === 'root') {
|
||||
endpoint = 'sites/root'
|
||||
} else if (siteId.includes(':')) {
|
||||
endpoint = `sites/${siteId}`
|
||||
} else if (siteId.includes('groups/')) {
|
||||
endpoint = siteId
|
||||
} else {
|
||||
endpoint = `sites/${siteId}`
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/${endpoint}?$select=id,name,displayName,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 site from SharePoint' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const site = await response.json()
|
||||
|
||||
const transformedSite = {
|
||||
id: site.id,
|
||||
name: site.displayName || site.name,
|
||||
mimeType: 'application/vnd.microsoft.graph.site',
|
||||
webViewLink: site.webUrl,
|
||||
createdTime: site.createdDateTime,
|
||||
modifiedTime: site.lastModifiedDateTime,
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully fetched SharePoint site: ${transformedSite.name}`)
|
||||
return NextResponse.json({ site: transformedSite }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching site from SharePoint`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sharepointSitesSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { SharepointSite } from '@/tools/sharepoint/types'
|
||||
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SharePointSitesAPI')
|
||||
|
||||
/**
|
||||
* Upper bound on Microsoft Graph pages drained when listing SharePoint sites.
|
||||
* Each page returns up to `$top=999` sites, so this caps the result set at
|
||||
* roughly 10k sites while preventing an unbounded server-side loop.
|
||||
*/
|
||||
const MAX_SITES_PAGES = 10
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(sharepointSitesSelectorContract, request, {})
|
||||
if (!parsed.success) {
|
||||
logger.warn(`[${requestId}] Invalid sites request data`)
|
||||
return parsed.response
|
||||
}
|
||||
const { credential, workflowId, query } = parsed.data.body
|
||||
|
||||
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.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to obtain valid access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const searchQuery = query || '*'
|
||||
let nextUrl: string | undefined =
|
||||
`https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=999`
|
||||
|
||||
const rawSites: SharepointSite[] = []
|
||||
for (let page = 0; page < MAX_SITES_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 sites from SharePoint' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (Array.isArray(data.value)) {
|
||||
rawSites.push(...data.value)
|
||||
}
|
||||
|
||||
const nextLink = getGraphNextPageUrl(data)
|
||||
nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined
|
||||
if (nextUrl && page === MAX_SITES_PAGES - 1) {
|
||||
logger.warn(
|
||||
`[${requestId}] SharePoint sites pagination hit ${MAX_SITES_PAGES}-page cap; result may be incomplete`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sites = rawSites.map((site: SharepointSite) => ({
|
||||
id: site.id,
|
||||
name: site.displayName || site.name,
|
||||
mimeType: 'application/vnd.microsoft.graph.site',
|
||||
webViewLink: site.webUrl,
|
||||
createdTime: site.createdDateTime,
|
||||
modifiedTime: site.lastModifiedDateTime,
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Successfully fetched ${sites.length} SharePoint sites`)
|
||||
return NextResponse.json({ files: sites }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching sites from SharePoint`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sharepointUploadContract } from '@/lib/api/contracts/tools/microsoft'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
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 { processFilesToUserFiles } 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 type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
|
||||
import type { SharepointSkippedFile, SharepointUploadError } from '@/tools/sharepoint/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SharepointUploadAPI')
|
||||
const MAX_SHAREPOINT_UPLOAD_BYTES = 250 * 1024 * 1024
|
||||
|
||||
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 SharePoint upload attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated SharePoint upload request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const parsed = await parseRequest(sharepointUploadContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Uploading files to SharePoint`, {
|
||||
siteId: validatedData.siteId,
|
||||
driveId: validatedData.driveId,
|
||||
folderPath: validatedData.folderPath,
|
||||
hasFiles: !!(validatedData.files && validatedData.files.length > 0),
|
||||
fileCount: validatedData.files?.length || 0,
|
||||
})
|
||||
|
||||
if (!validatedData.files || validatedData.files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'At least one file is required for upload',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger)
|
||||
|
||||
if (userFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No valid files to upload',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const siteId = validatedData.siteId.trim() || 'root'
|
||||
const driveId = validatedData.driveId?.trim() || null
|
||||
const uploadedFiles: MicrosoftGraphDriveItem[] = []
|
||||
const skippedFiles: SharepointSkippedFile[] = []
|
||||
const errors: SharepointUploadError[] = []
|
||||
|
||||
for (const userFile of userFiles) {
|
||||
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
|
||||
if (denied) return denied
|
||||
logger.info(`[${requestId}] Uploading file: ${userFile.name}`)
|
||||
|
||||
let buffer: Buffer
|
||||
let downloadedContentType = ''
|
||||
try {
|
||||
const result = await downloadServableFileFromStorage(userFile, requestId, logger)
|
||||
buffer = result.buffer
|
||||
downloadedContentType = result.contentType
|
||||
} catch (error) {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
throw error
|
||||
}
|
||||
|
||||
const fileName = validatedData.fileName || userFile.name
|
||||
const folderPath = validatedData.folderPath?.trim() || ''
|
||||
|
||||
const fileSizeMB = buffer.length / (1024 * 1024)
|
||||
|
||||
if (buffer.length > MAX_SHAREPOINT_UPLOAD_BYTES) {
|
||||
logger.warn(
|
||||
`[${requestId}] File ${fileName} is ${fileSizeMB.toFixed(2)}MB, exceeds 250MB limit`
|
||||
)
|
||||
skippedFiles.push({
|
||||
name: fileName,
|
||||
size: buffer.length,
|
||||
limit: MAX_SHAREPOINT_UPLOAD_BYTES,
|
||||
reason: 'File exceeds the 250 MB Microsoft Graph small upload limit',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
let uploadPath = ''
|
||||
if (folderPath) {
|
||||
const normalizedPath = folderPath.startsWith('/') ? folderPath : `/${folderPath}`
|
||||
const cleanPath = normalizedPath.endsWith('/')
|
||||
? normalizedPath.slice(0, -1)
|
||||
: normalizedPath
|
||||
uploadPath = `${cleanPath}/${fileName}`
|
||||
} else {
|
||||
uploadPath = `/${fileName}`
|
||||
}
|
||||
|
||||
const encodedPath = uploadPath
|
||||
.split('/')
|
||||
.map((segment) => (segment ? encodeURIComponent(segment) : ''))
|
||||
.join('/')
|
||||
|
||||
const uploadUrl = driveId
|
||||
? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:${encodedPath}:/content`
|
||||
: `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drive/root:${encodedPath}:/content`
|
||||
|
||||
logger.info(`[${requestId}] Uploading to: ${uploadUrl}`)
|
||||
|
||||
const uploadResponse = await secureFetchWithValidation(
|
||||
uploadUrl,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type': downloadedContentType || userFile.type || 'application/octet-stream',
|
||||
},
|
||||
body: buffer,
|
||||
},
|
||||
'uploadUrl'
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorData = await uploadResponse.json().catch(() => ({}))
|
||||
logger.error(`[${requestId}] Failed to upload file ${fileName}:`, errorData)
|
||||
|
||||
if (uploadResponse.status === 409) {
|
||||
// File exists - retry with conflict behavior set to replace
|
||||
logger.warn(`[${requestId}] File ${fileName} already exists, retrying with replace`)
|
||||
const replaceUrl = `${uploadUrl}?@microsoft.graph.conflictBehavior=replace`
|
||||
const replaceResponse = await secureFetchWithValidation(
|
||||
replaceUrl,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
'Content-Type':
|
||||
downloadedContentType || userFile.type || 'application/octet-stream',
|
||||
},
|
||||
body: buffer,
|
||||
},
|
||||
'replaceUrl'
|
||||
)
|
||||
|
||||
if (!replaceResponse.ok) {
|
||||
const replaceErrorData = (await replaceResponse.json().catch(() => ({}))) as {
|
||||
error?: { message?: string }
|
||||
}
|
||||
logger.error(`[${requestId}] Failed to replace file ${fileName}:`, replaceErrorData)
|
||||
errors.push({
|
||||
name: fileName,
|
||||
status: replaceResponse.status,
|
||||
error: replaceErrorData.error?.message || `Failed to replace file: ${fileName}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const replaceData = (await replaceResponse.json()) as {
|
||||
id: string
|
||||
name: string
|
||||
webUrl: string
|
||||
size: number
|
||||
createdDateTime: string
|
||||
lastModifiedDateTime: string
|
||||
}
|
||||
logger.info(`[${requestId}] File replaced successfully: ${fileName}`)
|
||||
|
||||
uploadedFiles.push({
|
||||
id: replaceData.id,
|
||||
name: replaceData.name,
|
||||
webUrl: replaceData.webUrl,
|
||||
size: replaceData.size,
|
||||
createdDateTime: replaceData.createdDateTime,
|
||||
lastModifiedDateTime: replaceData.lastModifiedDateTime,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
errors.push({
|
||||
name: fileName,
|
||||
status: uploadResponse.status,
|
||||
error:
|
||||
(errorData as { error?: { message?: string } }).error?.message ||
|
||||
`Failed to upload file: ${fileName}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const uploadData = (await uploadResponse.json()) as MicrosoftGraphDriveItem
|
||||
logger.info(`[${requestId}] File uploaded successfully: ${fileName}`)
|
||||
|
||||
uploadedFiles.push({
|
||||
id: uploadData.id,
|
||||
name: uploadData.name,
|
||||
webUrl: uploadData.webUrl,
|
||||
size: uploadData.size,
|
||||
createdDateTime: uploadData.createdDateTime,
|
||||
lastModifiedDateTime: uploadData.lastModifiedDateTime,
|
||||
})
|
||||
}
|
||||
|
||||
if (uploadedFiles.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'No files were uploaded successfully',
|
||||
output: {
|
||||
uploadedFiles,
|
||||
fileCount: 0,
|
||||
skippedFiles,
|
||||
skippedCount: skippedFiles.length,
|
||||
errors,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Completed SharePoint upload`, {
|
||||
uploadedCount: uploadedFiles.length,
|
||||
skippedCount: skippedFiles.length,
|
||||
errorCount: errors.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
uploadedFiles,
|
||||
fileCount: uploadedFiles.length,
|
||||
skippedFiles,
|
||||
skippedCount: skippedFiles.length,
|
||||
errors,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error uploading files to SharePoint:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: toError(error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user