import type { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { NextResponse } from 'next/server' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { validateS3BucketName } from '@/lib/core/security/input-validation' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import type { RawFileInput } from '@/lib/uploads/utils/file-utils' import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' type RouteLogger = ReturnType /** Thrown by AWS SDK call sites so route handlers can map failures to the right HTTP status. */ export class TextractRouteError extends Error { status: number constructor(message: string, status = 500) { super(message) this.name = 'TextractRouteError' this.status = status } } export function textractErrorResponse( error: unknown, requestId: string, logger: RouteLogger ): NextResponse { const notReady = docNotReadyResponse(error) if (notReady) return notReady logger.error(`[${requestId}] Error in Textract request:`, error) const status = error instanceof TextractRouteError ? error.status : 500 return NextResponse.json( { success: false, error: getErrorMessage(error, 'Internal server error') }, { status } ) } /** * Maps an AWS SDK TextractClient rejection to a client-facing error, with a friendly hint for the * common "PDF used in single-page mode" mistake. The real AWS HTTP status (including 5xx) is * passed through so the tool-execution layer's retry logic can still treat throttling/internal * errors as retryable, matching the pre-migration hand-rolled-signing behavior. */ export function mapTextractSdkError( error: unknown, isPdf: boolean, options?: { hasAsyncMode?: boolean } ): TextractRouteError { const err = error as { name?: string message?: string $metadata?: { httpStatusCode?: number } } const hasAsyncMode = options?.hasAsyncMode ?? true const isUnsupportedFormat = err.name === 'UnsupportedDocumentException' || Boolean(err.message?.toLowerCase().includes('unsupported document')) if (isUnsupportedFormat && isPdf) { const hint = hasAsyncMode ? ' If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.' : ' Only JPEG, PNG, and single-page PDF files are supported.' return new TextractRouteError(`This document format is not supported.${hint}`, 400) } const status = err.$metadata?.httpStatusCode ?? 500 return new TextractRouteError(err.message || 'Textract API error', status) } export interface ResolvedDocument { bytes: Buffer contentType: string isPdf: boolean } export type ResolveDocumentResult = | { ok: true; document: ResolvedDocument } | { ok: false; response: NextResponse } /** Passes through the document host's real HTTP status on failure, so tool-execution retry logic can still treat a transient 5xx as retryable. */ async function fetchDocumentBytes(url: string): Promise<{ bytes: Buffer; contentType: string }> { const urlValidation = await validateUrlWithDNS(url, 'Document URL') if (!urlValidation.isValid) { throw new TextractRouteError(urlValidation.error || 'Invalid document URL', 400) } const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { method: 'GET', }) if (!response.ok) { await response.text().catch(() => {}) throw new TextractRouteError( `Failed to fetch document: ${response.statusText}`, response.status ) } const arrayBuffer = await response.arrayBuffer() const contentType = response.headers.get('content-type') || 'application/octet-stream' return { bytes: Buffer.from(arrayBuffer), contentType } } /** Resolves a document input (uploaded file reference or URL) to raw bytes for the Textract Document.Bytes field. */ export async function resolveDocumentInput( input: { file?: RawFileInput; filePath?: string }, userId: string, requestId: string, logger: RouteLogger ): Promise { if (input.file) { let userFile: ReturnType try { userFile = processSingleFileToUserFile(input.file, requestId, logger) } catch (error) { return { ok: false, response: NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to process file') }, { status: 400 } ), } } const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) if (denied) return { ok: false, response: denied } const { buffer, contentType } = await downloadServableFileFromStorage( userFile, requestId, logger ) const resolvedContentType = contentType || userFile.type || 'application/octet-stream' return { ok: true, document: { bytes: buffer, contentType: resolvedContentType, isPdf: resolvedContentType.includes('pdf') || Boolean(userFile.name?.toLowerCase().endsWith('.pdf')), }, } } if (input.filePath) { let fileUrl = input.filePath const isInternalFilePath = isInternalFileUrl(fileUrl) if (isInternalFilePath) { const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) if (resolution.error) { return { ok: false, response: NextResponse.json( { success: false, error: resolution.error.message }, { status: resolution.error.status } ), } } fileUrl = resolution.fileUrl || fileUrl } else if (fileUrl.startsWith('/')) { logger.warn(`[${requestId}] Invalid internal path`, { userId, path: fileUrl.substring(0, 50), }) return { ok: false, response: NextResponse.json( { success: false, error: 'Invalid file path. Only uploaded files are supported for internal paths.', }, { status: 400 } ), } } else { const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL') if (!urlValidation.isValid) { logger.warn(`[${requestId}] SSRF attempt blocked`, { userId, url: fileUrl.substring(0, 100), error: urlValidation.error, }) return { ok: false, response: NextResponse.json( { success: false, error: urlValidation.error }, { status: 400 } ), } } } const fetched = await fetchDocumentBytes(fileUrl) return { ok: true, document: { bytes: fetched.bytes, contentType: fetched.contentType, isPdf: fetched.contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf'), }, } } return { ok: false, response: NextResponse.json( { success: false, error: 'Document input is required' }, { status: 400 } ), } } export function parseS3Uri(s3Uri: string): { bucket: string; key: string } { const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/) if (!match) { throw new TextractRouteError( `Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object`, 400 ) } const bucket = match[1] const key = match[2] const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name') if (!bucketValidation.isValid) { throw new TextractRouteError(bucketValidation.error || 'Invalid S3 bucket name', 400) } if (key.includes('..') || key.startsWith('/')) { throw new TextractRouteError('S3 key contains invalid path traversal sequences', 400) } return { bucket, key } } interface PollableJobResult { JobStatus?: string StatusMessage?: string NextToken?: string } /** Polls a started async Textract job (StartDocumentAnalysis/StartDocumentTextDetection/StartExpenseAnalysis) until it completes, following NextToken pagination on success. */ export async function pollTextractJob( requestId: string, logger: RouteLogger, getPage: (nextToken?: string) => Promise, mergePage: (accumulated: TResult, page: TResult) => TResult ): Promise { const pollIntervalMs = 5000 const maxPollTimeMs = getMaxExecutionTimeout() const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) for (let attempt = 0; attempt < maxAttempts; attempt++) { const result = await getPage() const jobStatus = result.JobStatus if (jobStatus === 'SUCCEEDED' || jobStatus === 'PARTIAL_SUCCESS') { if (jobStatus === 'PARTIAL_SUCCESS') { logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) } else { logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`) } let merged = result let nextToken = result.NextToken while (nextToken) { const page = await getPage(nextToken) merged = mergePage(merged, page) nextToken = page.NextToken } return merged } if (jobStatus === 'FAILED') { throw new TextractRouteError( `Textract job failed: ${result.StatusMessage || 'Unknown error'}`, 502 ) } logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`) await sleep(pollIntervalMs) } throw new TextractRouteError( `Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`, 504 ) }