import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { googleDriveFilesSelectorContract } from '@/lib/api/contracts/selectors/google' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveFilesAPI') const MAX_DRIVE_FILE_PAGES = 20 const DRIVE_FILE_PAGE_SIZE = 100 interface DriveFilesResponse { files?: DriveFile[] nextPageToken?: string } function escapeForDriveQuery(value: string): string { return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") } interface SharedDrive { id: string name: string kind: string } interface DriveFile { id: string name: string mimeType: string iconLink?: string webViewLink?: string thumbnailLink?: string createdTime?: string modifiedTime?: string size?: string owners?: unknown[] parents?: string[] } /** * Fetches shared drives the user has access to */ async function fetchSharedDrives(accessToken: string, requestId: string): Promise { try { const response = await fetch( 'https://www.googleapis.com/drive/v3/drives?pageSize=100&fields=drives(id,name)', { headers: { Authorization: `Bearer ${accessToken}`, }, } ) if (!response.ok) { logger.warn(`[${requestId}] Failed to fetch shared drives`, { status: response.status, }) return [] } const data = await response.json() const drives: SharedDrive[] = data.drives || [] return drives.map((drive) => ({ id: drive.id, name: drive.name, mimeType: 'application/vnd.google-apps.folder', iconLink: 'https://ssl.gstatic.com/docs/doclist/images/icon_11_shared_collection_list_1.png', })) } catch (error) { logger.error(`[${requestId}] Error fetching shared drives`, error) return [] } } export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Google Drive files request received`) const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } try { const parsed = await parseRequest( googleDriveFilesSelectorContract, request, {}, { validationErrorResponse: () => { logger.warn(`[${requestId}] Missing credential ID`) return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) }, } ) if (!parsed.success) return parsed.response const { credentialId, mimeType } = parsed.data.query const query = parsed.data.query.query || '' const folderId = parsed.data.query.folderId || parsed.data.query.parentId || '' const workflowId = parsed.data.query.workflowId || undefined const impersonateEmail = parsed.data.query.impersonateEmail || undefined const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, requestId, getScopesForService('google-drive'), impersonateEmail ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } if (folderId) { const folderIdValidation = validateAlphanumericId(folderId, 'folderId', 50) if (!folderIdValidation.isValid) { logger.warn(`[${requestId}] Invalid folderId`, { error: folderIdValidation.error }) return NextResponse.json({ error: folderIdValidation.error }, { status: 400 }) } } const qParts: string[] = ['trashed = false'] if (folderId) { qParts.push(`'${escapeForDriveQuery(folderId)}' in parents`) } if (mimeType) { qParts.push(`mimeType = '${escapeForDriveQuery(mimeType)}'`) } if (query) { qParts.push(`name contains '${escapeForDriveQuery(query)}'`) } const q = qParts.join(' and ') let files: DriveFile[] try { const drained = await drainGooglePagedList({ buildUrl: (pageToken) => { const url = new URL('https://www.googleapis.com/drive/v3/files') url.searchParams.set('q', q) url.searchParams.set('corpora', 'allDrives') url.searchParams.set('supportsAllDrives', 'true') url.searchParams.set('includeItemsFromAllDrives', 'true') url.searchParams.set('pageSize', String(DRIVE_FILE_PAGE_SIZE)) url.searchParams.set( 'fields', 'nextPageToken,files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)' ) if (pageToken) url.searchParams.set('pageToken', pageToken) return url.toString() }, fetch: (url) => fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, }, }), parseError: (response) => response.json().catch(() => ({ error: { message: 'Unknown error' } })), getItems: (body) => body.files, getNextPageToken: (body) => body.nextPageToken, maxPages: MAX_DRIVE_FILE_PAGES, label: 'Google Drive files', }) files = drained.items } catch (error) { if (error instanceof GooglePageError) { const errorBody = error.body as { error?: { message?: string } } logger.error(`[${requestId}] Google Drive API error`, { status: error.status, error: errorBody?.error?.message || 'Failed to fetch files from Google Drive', }) return NextResponse.json( { error: errorBody?.error?.message || 'Failed to fetch files from Google Drive', }, { status: error.status } ) } throw error } if (mimeType === 'application/vnd.google-apps.spreadsheet') { files = files.filter( (file: DriveFile) => file.mimeType === 'application/vnd.google-apps.spreadsheet' ) } else if (mimeType === 'application/vnd.google-apps.document') { files = files.filter( (file: DriveFile) => file.mimeType === 'application/vnd.google-apps.document' ) } const isRootFolderListing = !folderId && mimeType === 'application/vnd.google-apps.folder' && !query if (isRootFolderListing) { const sharedDrives = await fetchSharedDrives(accessToken, requestId) if (sharedDrives.length > 0) { logger.info(`[${requestId}] Found ${sharedDrives.length} shared drives`) files = [...sharedDrives, ...files] } } return NextResponse.json({ files }, { status: 200 }) } catch (error) { if (error instanceof ServiceAccountTokenError) { logger.warn(`[${requestId}] Service account token error`, { message: error.message }) return NextResponse.json({ error: error.message }, { status: 400 }) } logger.error(`[${requestId}] Error fetching files from Google Drive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } })