| |
| |
| |
|
|
|
|
| import { z } from "zod";
|
| import { NextRequest, NextResponse } from "next/server";
|
| import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
| import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
| import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
|
|
| const ALLOWED_TRY_PATH_PREFIXES = ["/api/", "/v1/", "/v1beta/", "/a2a", "/.well-known/agent.json"];
|
| const BLOCKED_FORWARD_HEADERS = new Set([
|
| "connection",
|
| "content-length",
|
| "cookie",
|
| "host",
|
| "keep-alive",
|
| "proxy-authenticate",
|
| "proxy-authorization",
|
| "te",
|
| "trailer",
|
| "transfer-encoding",
|
| "upgrade",
|
| "x-forwarded-for",
|
| "x-forwarded-host",
|
| "x-forwarded-proto",
|
| ]);
|
|
|
| const tryRequestSchema = z.object({
|
| method: z
|
| .enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
| .optional()
|
| .default("GET"),
|
| path: z
|
| .string()
|
| .min(1, "Path is required")
|
| .startsWith("/", "Path must start with /")
|
| .refine((value) => !value.startsWith("//"), "Path must be a same-origin path")
|
| .refine(
|
| (value) => ALLOWED_TRY_PATH_PREFIXES.some((prefix) => value.startsWith(prefix)),
|
| "Path must target an OmniRoute API endpoint"
|
| ),
|
| headers: z.record(z.string(), z.string()).optional().default({}),
|
| body: z.any().optional(),
|
| });
|
|
|
| function getRequestOrigin(request: NextRequest) {
|
| return request.nextUrl?.origin || new URL(request.url).origin;
|
| }
|
|
|
| function buildForwardHeaders(headers: Record<string, string>) {
|
| const forwardHeaders: Record<string, string> = {};
|
|
|
| for (const [key, value] of Object.entries(headers)) {
|
| const normalizedKey = key.trim().toLowerCase();
|
| if (!normalizedKey || BLOCKED_FORWARD_HEADERS.has(normalizedKey)) continue;
|
| forwardHeaders[key] = value;
|
| }
|
|
|
| return forwardHeaders;
|
| }
|
|
|
| export async function POST(request: NextRequest) {
|
| const authError = await requireManagementAuth(request);
|
| if (authError) return authError;
|
|
|
| try {
|
| const rawBody = await request.json();
|
| const validation = validateBody(tryRequestSchema, rawBody);
|
| if (isValidationFailure(validation)) {
|
| return NextResponse.json({ error: validation.error }, { status: 400 });
|
| }
|
|
|
| const { method, path, headers, body: reqBody } = validation.data;
|
|
|
| const origin = getRequestOrigin(request);
|
| const targetUrl = new URL(path, origin);
|
| if (targetUrl.origin !== origin) {
|
| return NextResponse.json({ error: "Path must be same-origin" }, { status: 400 });
|
| }
|
|
|
| const start = performance.now();
|
|
|
|
|
| const forwardHeaders = buildForwardHeaders(headers as Record<string, string>);
|
|
|
|
|
| const cookie = request.headers.get("cookie");
|
| if (cookie && !forwardHeaders["Cookie"]) {
|
| forwardHeaders["Cookie"] = cookie;
|
| }
|
|
|
| if (reqBody && !forwardHeaders["Content-Type"]) {
|
| forwardHeaders["Content-Type"] = "application/json";
|
| }
|
|
|
| const fetchOptions: RequestInit = {
|
| method: method.toUpperCase(),
|
| headers: forwardHeaders,
|
| };
|
|
|
| if (reqBody && method.toUpperCase() !== "GET") {
|
| fetchOptions.body = typeof reqBody === "string" ? reqBody : JSON.stringify(reqBody);
|
| }
|
|
|
| const res = await fetch(targetUrl, fetchOptions);
|
| const latencyMs = Math.round(performance.now() - start);
|
|
|
|
|
| const contentType = res.headers.get("content-type") || "";
|
| let responseBody: any;
|
|
|
| if (contentType.includes("application/json")) {
|
| responseBody = await res.json();
|
| } else {
|
| const text = await res.text();
|
|
|
| responseBody = text.length > 10000 ? text.slice(0, 10000) + "\n... (truncated)" : text;
|
| }
|
|
|
|
|
| const responseHeaders: Record<string, string> = {};
|
| res.headers.forEach((value, key) => {
|
| responseHeaders[key] = value;
|
| });
|
|
|
| return NextResponse.json({
|
| status: res.status,
|
| statusText: res.statusText,
|
| headers: responseHeaders,
|
| body: responseBody,
|
| latencyMs,
|
| contentType,
|
| });
|
| } catch (error: any) {
|
| return NextResponse.json(
|
| {
|
| status: 0,
|
| statusText: "Network Error",
|
| headers: {},
|
| body: { error: sanitizeErrorMessage(error) || "Request failed" },
|
| latencyMs: 0,
|
| contentType: "application/json",
|
| },
|
| { status: 200 }
|
| );
|
| }
|
| }
|
|
|