Files
Endoleap/src/app/api/foundry/[...path]/route.ts
T

228 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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);
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 };