Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

306 lines
9.8 KiB
TypeScript

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<typeof createLogger>
/** 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<ResolveDocumentResult> {
if (input.file) {
let userFile: ReturnType<typeof processSingleFileToUserFile>
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<TResult extends PollableJobResult>(
requestId: string,
logger: RouteLogger,
getPage: (nextToken?: string) => Promise<TResult>,
mergePage: (accumulated: TResult, page: TResult) => TResult
): Promise<TResult> {
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
)
}