@@ -1,14 +1,25 @@
/**
* Foundry的同源服务端代理。浏览器只持HttpOnly会话Cookie,代理转为上游Bearer;
* 限定可转发路径/查询/header,处理体积、超时、素材类型和会话续期。领域回执/模型校验仍由客户端服务适配器执行。
* canonical文档和AI冻结写入保留原字节,模型/图片大响应流式转发,避免代理重编码破坏请求身份或额外缓存整份模型。
*/
import { fetchCanvasResponse } from "@/lib/canvas-response" ;
import { sessionCookie , sessionMaxAge , SESSION_EXPIRY_HEADER } from "@/lib/session-cookie" ;
import { NextRequest , NextResponse } from "next/server" ;
/** 普通账户/工作区/material接口白名单;canvas与asset细分端点由下方独立表达式允许。 */
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/ ;
/** 明确列出旧canvas、新model增量/迁移、子图事务和AI端点,拒绝把代理变成任意上游路径通道。 */
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/ ;
/** 素材上传/原件读取、操作回执和授权元数据是不同路径,后续按类别选择解析与响应方式。 */
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:(?:operations|metadata)\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/ ;
/** 普通请求体上限1MiB;画布5MiB、multipart素材21MiB由handler显式覆盖。 */
const bodyLimit = 1024 * 1024 ;
// Route Handlers do not have the Pages Router bodyParser size limit.
/** 逐块计数后再组装请求体,超限取消reader,finally释放锁。
* binary原样返回字节供冻结JSON/multipart转发,其它请求才解析JSON;这不是无限body缓冲。 */
async function readBody ( req : NextRequest , limit = bodyLimit , binary = false ) {
const reader = req . body ? . getReader ( ) ;
if ( ! reader ) return { } ;
@@ -28,11 +39,14 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
} finally {
reader . releaseLock ( ) ;
}
// 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。
if ( ! size ) return binary ? new Uint8Array ( ) : { } ;
const bytes = Buffer . concat ( chunks ) ;
return binary ? bytes : JSON.parse ( bytes . toString ( "utf8" ) ) ;
}
/** 共用GET/POST/PATCH/DELETE处理器。params是当前Next版本的异步路由参数,先await再匹配路径。
* 所有本代理响应默认no-store;HTTP成功/失败保持上游状态,上游调用/响应解析异常另转为502或超时504。 */
async function handler (
req : NextRequest ,
{ params } : { params : Promise < { path : string [ ] } > } ,
@@ -40,8 +54,10 @@ async function handler(
const requestStarted = performance . now ( ) ;
const requestStartedAt = new Date ( ) . toISOString ( ) ;
const responseHeaders = new Headers ( { "Cache-Control" : "no-store" } ) ;
/** 复用当前响应头集合,JSON错误和普通结果都带相同缓存/续期/诊断头策略。 */
const json = ( body : unknown , status : number ) = >
NextResponse . json ( body , { status , headers : responseHeaders } ) ;
// 非GET且带Origin时比对host;缺Origin的请求在此不被拒绝,最终身份/权限仍由上游校验。
const requestOrigin = req . headers . get ( "origin" ) ;
if ( req . method !== "GET" && requestOrigin ) {
try {
@@ -53,6 +69,7 @@ async function handler(
}
const path = ( await params ) . path . join ( "/" ) ;
if ( ! allowed . test ( path ) && ! canvasRoute . test ( path ) && ! assetRoute . test ( path ) ) return json ( { error : "not_found" } , 404 ) ;
// operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。
const assetReceipt = assetRoute . test ( path ) && /\/assets\/operations\// . test ( path ) ;
const assetMetadata = assetRoute . test ( path ) && /\/assets\/metadata\// . test ( path ) ;
const asset = assetRoute . test ( path ) && ! assetReceipt && ! assetMetadata ;
@@ -60,6 +77,7 @@ async function handler(
const canonical = canvas && /\/canvas\/model(?:\/|$)/ . test ( path ) ;
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/ . test ( path ) ;
let requestBody : unknown ;
// 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。
if ( req . method !== "GET" ) {
try {
requestBody = await readBody ( req , asset ? 21 * 1024 * 1024 : canonical || path . endsWith ( "/canvas" ) || path . endsWith ( "/canvas/operations" ) ? 5 * 1024 * 1024 : bodyLimit , asset || canonical || frozenAI ) ;
@@ -69,18 +87,24 @@ async function handler(
}
}
const bodyReceived = performance . now ( ) ;
// 上游地址来自服务端环境,客户端path白名单不会让调用者自己指定任意目标源站。
const origin = process . env . FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com" ;
const project = process . env . NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap" ;
// multipart沿用浏览器Content-Type和boundary,其余发JSON;不透传浏览器任意请求头。
const headers : Record < string , string > = { "Content-Type" : asset ? ( req . headers . get ( "content-type" ) || "application/octet-stream" ) : "application/json" } ;
if ( canvas ) headers . Accept = "application/vnd.foundry.canvas+json;version=2" ;
// 仅精确协商的canonical GET模型流改变Accept,普通canvas请求继续兼容v2表示。
const modelStream = canonical && path . endsWith ( '/canvas/model' ) && req . method === 'GET' && req . headers . get ( 'accept' ) === 'application/vnd.foundry.canvas-model+ndjson;version=1' ;
if ( modelStream ) headers . Accept = 'application/vnd.foundry.canvas-model+ndjson;version=1' ;
// Bearer从服务端读Cookie,禁止直接依赖客户端自行提供的Authorization冒充当前会话。
const session = req . cookies . get ( "foundry_session" ) ? . value ;
if ( session ) headers . Authorization = "Bearer " + session ;
// 明确允许OAuth交换、条件版本、幂等键、账户绑定和资源ID;这些值仍需上游验证。
for ( const h of [ "x-foundry-code" , "x-foundry-verifier" , "if-match" , "idempotency-key" , "x-expected-account" , "x-resource-id" ] ) {
const value = req . headers . get ( h ) ;
if ( value !== null ) headers [ h ] = value ;
}
// 查询参数限定名单、单值且每值<300字符,重复值不选择性取第一项,避免前后端解释分歧。
const query = new URLSearchParams ( ) ;
for ( const key of [ "kind" , "sort" , "q" , "limit" , "offset" , "after_seq" , "status" , "include_descendants" , "include_subgraphs" , "cursor" , "variant" , "width" ] ) {
const values = req . nextUrl . searchParams . getAll ( key ) ;
@@ -88,18 +112,22 @@ async function handler(
}
const encodingStarted = performance . now ( ) ;
// Frozen document and AI requests cross the proxy byte-for-byte.
// binary Buffer直接转发,不JSON.parse/stringify;普通业务JSON才重编码,GET不带body。
const outboundBody = req . method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify ( requestBody || { } ) ;
const encodeMs = performance . now ( ) - encodingStarted ;
const started = performance . now ( ) ;
try {
// 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。
const snapshotRead = canvas && ( path . endsWith ( "/canvas" ) || canonical ) && req . method === "GET" ;
const streamedRead = snapshotRead || asset && req . method === "GET" ;
const snapshotWrite = canvas && path . endsWith ( "/canvas" ) && req . method === "PATCH" ;
const traceId = streamedRead || snapshotWrite ? crypto . randomUUID ( ) : "" ;
/** 大响应只记录传输时序/字节数/资源路径,不把图像/模型内容或会话令牌写入trace。 */
const requestUpstream = streamedRead ? ( url : string , options : RequestInit ) = > fetchCanvasResponse ( url , options , undefined , fetch , ( trace ) = > {
console . info ( snapshotRead ? "[canvas-transfer]" : "[asset-transfer]" , JSON . stringify ( { trace_id : traceId , resource : path , . . . trace } ) ) ;
} ) : fetch ;
if ( streamedRead || snapshotWrite ) responseHeaders . set ( "X-Canvas-Trace-Id" , traceId ) ;
// 禁止fetch缓存和自动跟随上游跳转;流读取继承请求取消,具体头/body/idle计时由fetchCanvasResponse管理。
const upstream = await requestUpstream (
origin + "/projects/" + encodeURIComponent ( project ) + "/" + path +
( query . size ? "?" + query . toString ( ) : "" ) ,
@@ -112,17 +140,20 @@ async function handler(
cache : "no-store" ,
} ,
) ;
// 转发明确允许的重试、版本和schema提示,不能用代理自己的时间推算服务端写入版本。
const retryAfter = upstream . headers . get ( "retry-after" ) ;
if ( retryAfter ) responseHeaders . set ( "Retry-After" , retryAfter ) ;
const etag = upstream . headers . get ( "etag" ) ;
if ( etag ) responseHeaders . set ( "ETag" , etag ) ;
const schema = upstream . headers . get ( "x-canvas-schema-version" ) ;
if ( schema ) responseHeaders . set ( "X-Canvas-Schema-Version" , schema ) ;
// 既有会话仅在非401且上游给合法剩余秒数时续期;Secure还取决于生产环境及代理HTTPS头。
const renewal = upstream . headers . get ( SESSION_EXPIRY_HEADER ) ;
if ( session && upstream . status !== 401 && renewal !== null && /^\d+$/ . test ( renewal ) ) {
const secure = process . env . NODE_ENV === "production" && req . headers . get ( "x-forwarded-proto" ) === "https" ;
responseHeaders . set ( "Set-Cookie" , sessionCookie ( session , sessionMaxAge ( renewal ) , secure ) ) ;
}
// 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。
if ( asset && upstream . ok && req . method === "GET" ) {
const type = upstream . headers . get ( "content-type" ) || "" ;
if ( ! /^image\/(png|jpeg|webp|gif)(?:;|$)/i . test ( type ) ) { await upstream . body ? . cancel ( ) ; return json ( { error : "invalid_asset" } , 502 ) ; }
@@ -135,12 +166,15 @@ async function handler(
const headersReceived = performance . now ( ) ;
// Snapshots contain no login exchange payload. Forward them without buffering
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
// canonical含错误回执也原流透传;旧snapshot只有成功GET走此路径。此处不读取/删除模型payload字段。
if ( canonical || canvas && path . endsWith ( "/canvas" ) && req . method === "GET" && upstream . ok ) {
responseHeaders . set ( "Content-Type" , modelStream && upstream . ok && upstream . headers . get ( 'content-type' ) ? . startsWith ( 'application/vnd.foundry.canvas-model+ndjson;' ) ? upstream . headers . get ( 'content-type' ) ! : "application/json; charset=utf-8" ) ;
responseHeaders . set ( "Server-Timing" , ` upstream;dur= ${ ( headersReceived - started ) . toFixed ( 1 ) } , proxy_headers;dur= ${ ( performance . now ( ) - headersReceived ) . toFixed ( 1 ) } ` ) ;
return new NextResponse ( upstream . body , { status : upstream.status , headers : responseHeaders } ) ;
}
// 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。
const body = upstream . status === 204 ? null : await upstream . json ( ) ;
// 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。
if ( snapshotWrite ) console . info ( "[canvas-save]" , JSON . stringify ( {
trace_id : traceId , resource : path , status : upstream.status , started_at : requestStartedAt , finished_at : new Date ( ) . toISOString ( ) ,
client_body_ms : Math.round ( ( bodyReceived - requestStarted ) * 10 ) / 10 ,
@@ -155,6 +189,7 @@ async function handler(
` upstream;dur= ${ ( headersReceived - started ) . toFixed ( 1 ) } , total;dur= ${ ( performance . now ( ) - started ) . toFixed ( 1 ) } ` ,
) ;
const secure = process . env . NODE_ENV === "production" && req . headers . get ( "x-forwarded-proto" ) === "https" ;
// 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。
if ( body ? . access_token ) {
responseHeaders . set ( "Set-Cookie" , sessionCookie ( body . access_token , sessionMaxAge ( body . expires_in ) , secure ) ) ;
delete body . access_token ;
@@ -162,6 +197,7 @@ async function handler(
const renewed = upstream . headers . get ( SESSION_EXPIRY_HEADER ) ;
if ( renewed !== null && /^\d+$/ . test ( renewed ) ) responseHeaders . set ( "Set-Cookie" , sessionCookie ( session , sessionMaxAge ( renewed ) , secure ) ) ;
}
// 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。
if ( path === "auth/logout" && upstream . ok )
responseHeaders . set (
"Set-Cookie" ,
@@ -170,6 +206,7 @@ async function handler(
if ( upstream . status === 204 )
return new NextResponse ( null , { status : 204 , headers : responseHeaders } ) ;
return json ( body || { error : "upstream_unavailable" } , upstream . status ) ;
// 这里只归一化代理传输/解析错误;成功开始转发后的流错误由流自身传播,不能补发第二个JSON响应。
} catch ( error ) {
responseHeaders . set ( "Server-Timing" , ` total;dur= ${ ( performance . now ( ) - started ) . toFixed ( 1 ) } ` ) ;
const timeout = error instanceof Error && error . name === "TimeoutError" ;
@@ -177,6 +214,7 @@ async function handler(
}
}
/** 显式拒绝PUT/HEAD/OPTIONS并说明允许方法,不依赖框架自动推导另一套接口行为。 */
function methodNotAllowed() {
return NextResponse . json ( { error : "method_not_allowed" } , {
status : 405 ,
@@ -184,5 +222,6 @@ function methodNotAllowed() {
} ) ;
}
// 同一白名单/体积/会话策略覆盖允许的四种方法;具体操作是否合法仍由服务端端点决定。
export { handler as GET , handler as POST , handler as PATCH , handler as DELETE } ;
export { methodNotAllowed as PUT , methodNotAllowed as HEAD , methodNotAllowed as OPTIONS } ;