import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { jupyterProxyContract } from '@/lib/api/contracts/tools/jupyter' 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 { assertSafeJupyterProxyPath, buildJupyterAuthHeaders, normalizeJupyterServerUrl, UnsafeJupyterPathError, } from '@/tools/jupyter/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JupyterProxyAPI') /** * Proxies Contents/Kernels/Kernelspecs/Sessions API calls to a self-hosted * Jupyter server. Self-hosted servers have no fixed public host, so every * request is server-side (DNS-pinned, http(s) allowed, redirects disabled) * rather than going through the generic external tool executor, which blocks * plain-HTTP and private-IP hosts by default. Mirrors the upstream status * and body verbatim so callers can treat this exactly like a direct fetch. */ 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 Jupyter proxy attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } ) } const parsed = await parseRequest(jupyterProxyContract, request, {}) if (!parsed.success) return parsed.response const data = parsed.data.body try { assertSafeJupyterProxyPath(data.path) } catch (error) { if (error instanceof UnsafeJupyterPathError) { return NextResponse.json({ success: false, error: error.message }, { status: 400 }) } throw error } const base = normalizeJupyterServerUrl(data.serverUrl) const url = `${base}/api/${data.path}` const urlValidation = await validateUrlWithDNS(url, 'serverUrl', { allowHttp: true }) if (!urlValidation.isValid || !urlValidation.resolvedIP) { return NextResponse.json( { success: false, error: `Invalid Jupyter serverUrl: ${urlValidation.error}` }, { status: 400 } ) } const hasBody = data.body !== undefined && data.body !== null const upstream = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, { method: data.method, headers: { ...buildJupyterAuthHeaders(data.token), ...(hasBody ? { 'Content-Type': 'application/json' } : {}), }, body: hasBody ? JSON.stringify(data.body) : undefined, allowHttp: true, maxRedirects: 0, }) const text = await upstream.text() return new NextResponse(text.length > 0 ? text : null, { status: upstream.status, headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' }, }) } catch (error) { logger.error(`[${requestId}] Unexpected error:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Unknown error') }, { status: 500 } ) } })