/** * 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 {}; const chunks: Uint8Array[] = []; let size = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > limit) { await reader.cancel(); throw new Error("body_too_large"); } chunks.push(value); } } 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[] }> }, ) { 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 { if (new URL(requestOrigin).host !== req.headers.get("host")) return json({ error: "invalid_origin" }, 403); } catch { return json({ error: "invalid_origin" }, 403); } } 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; const canvas = canvasRoute.test(path); 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); } catch (error) { const tooLarge = error instanceof Error && error.message === "body_too_large"; return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400); } } 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 = { "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); if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); } 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() : ""), { method: req.method, headers, body: outboundBody, signal: streamedRead ? req.signal : AbortSignal.any([AbortSignal.timeout(15000), req.signal]), redirect: "manual", 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); } responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff"); const variant = upstream.headers.get("x-asset-variant"); if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant); responseHeaders.set("Server-Timing", `upstream;dur=${(performance.now() - started).toFixed(1)}`); return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); } 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, encode_ms: Math.round(encodeMs * 10) / 10, request_bytes: typeof outboundBody === "string" ? new TextEncoder().encode(outboundBody).length : 0, upload_and_server_ms: Math.round((headersReceived - started) * 10) / 10, response_body_ms: Math.round((performance.now() - headersReceived) * 10) / 10, total_ms: Math.round((performance.now() - requestStarted) * 10) / 10, })); responseHeaders.set( "Server-Timing", `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; } else if (session && upstream.status !== 401) { 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", "foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", ); 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"; return json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" }, timeout ? 504 : 502); } } /** 显式拒绝PUT/HEAD/OPTIONS并说明允许方法,不依赖框架自动推导另一套接口行为。 */ function methodNotAllowed() { return NextResponse.json({ error: "method_not_allowed" }, { status: 405, headers: { "Cache-Control": "no-store", Allow: "GET, POST, PATCH, DELETE" }, }); } // 同一白名单/体积/会话策略覆盖允许的四种方法;具体操作是否合法仍由服务端端点决定。 export { handler as GET, handler as POST, handler as PATCH, handler as DELETE }; export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };