199 lines
5.6 KiB
TypeScript
199 lines
5.6 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|
|
|
|
const SESSION_COOKIE = "mc_admin_session";
|
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60;
|
|
|
|
function candidateBases(): string[] {
|
|
const configured = [
|
|
process.env.MASTER_API_URL,
|
|
process.env.NEXT_PUBLIC_API_URL,
|
|
"http://master-api:3000",
|
|
"http://host.docker.internal:3100",
|
|
].filter((v): v is string => !!v && v.trim().length > 0);
|
|
|
|
return [...new Set(configured.map((v) => v.replace(/\/$/, "")))];
|
|
}
|
|
|
|
function isHttps(request: NextRequest): boolean {
|
|
const proto = request.headers.get("x-forwarded-proto");
|
|
if (proto) return proto.split(",")[0].trim() === "https";
|
|
return request.nextUrl.protocol === "https:";
|
|
}
|
|
|
|
function applySessionCookie(
|
|
response: NextResponse,
|
|
token: string,
|
|
request: NextRequest,
|
|
expiresAt?: string
|
|
) {
|
|
const secure = isHttps(request) || process.env.NODE_ENV === "production";
|
|
response.cookies.set({
|
|
name: SESSION_COOKIE,
|
|
value: token,
|
|
httpOnly: true,
|
|
secure,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: SESSION_MAX_AGE,
|
|
...(expiresAt ? { expires: new Date(expiresAt) } : {}),
|
|
});
|
|
}
|
|
|
|
function clearSessionCookie(response: NextResponse, request: NextRequest) {
|
|
const secure = isHttps(request) || process.env.NODE_ENV === "production";
|
|
response.cookies.set({
|
|
name: SESSION_COOKIE,
|
|
value: "",
|
|
httpOnly: true,
|
|
secure,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 0,
|
|
});
|
|
}
|
|
|
|
async function proxy(request: NextRequest, pathSegments: string[]) {
|
|
const targetPath = pathSegments.join("/");
|
|
const url = new URL(request.url);
|
|
const search = url.search;
|
|
const isLogin = request.method === "POST" && targetPath === "v1/auth/login";
|
|
const isLogout = request.method === "POST" && targetPath === "v1/auth/logout";
|
|
|
|
const headers = new Headers();
|
|
const contentType = request.headers.get("content-type");
|
|
if (contentType) headers.set("content-type", contentType);
|
|
const cookie = request.headers.get("cookie");
|
|
if (cookie) headers.set("cookie", cookie);
|
|
headers.set("accept", "application/json");
|
|
|
|
const body =
|
|
request.method !== "GET" && request.method !== "HEAD"
|
|
? await request.arrayBuffer()
|
|
: undefined;
|
|
|
|
const errors: string[] = [];
|
|
|
|
for (const base of candidateBases()) {
|
|
const target = `${base}/api/${targetPath}${search}`;
|
|
try {
|
|
const upstream = await fetch(target, {
|
|
method: request.method,
|
|
headers,
|
|
body,
|
|
redirect: "manual",
|
|
});
|
|
|
|
// Special-case auth so the session cookie is owned by admin.vonas.nl
|
|
if (isLogin) {
|
|
const text = await upstream.text();
|
|
let data: {
|
|
user?: unknown;
|
|
sessionToken?: string;
|
|
expiresAt?: string;
|
|
error?: unknown;
|
|
} = {};
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
return new NextResponse(text, {
|
|
status: upstream.status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const response = NextResponse.json(
|
|
{ user: data.user, error: data.error },
|
|
{ status: upstream.status }
|
|
);
|
|
|
|
if (upstream.ok && data.sessionToken) {
|
|
applySessionCookie(response, data.sessionToken, request, data.expiresAt);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
if (isLogout) {
|
|
const responseBody = await upstream.arrayBuffer();
|
|
const response = new NextResponse(responseBody, {
|
|
status: upstream.status,
|
|
headers: {
|
|
"content-type":
|
|
upstream.headers.get("content-type") ?? "application/json",
|
|
},
|
|
});
|
|
clearSessionCookie(response, request);
|
|
return response;
|
|
}
|
|
|
|
const responseBody = await upstream.arrayBuffer();
|
|
const response = new NextResponse(responseBody, { status: upstream.status });
|
|
response.headers.set(
|
|
"content-type",
|
|
upstream.headers.get("content-type") ?? "application/json"
|
|
);
|
|
|
|
// Forward any upstream cookies as fallback (rewritten without Domain)
|
|
const anyHeaders = upstream.headers as Headers & {
|
|
getSetCookie?: () => string[];
|
|
};
|
|
const setCookies =
|
|
typeof anyHeaders.getSetCookie === "function"
|
|
? anyHeaders.getSetCookie()
|
|
: [];
|
|
|
|
for (const raw of setCookies) {
|
|
const cleaned = raw
|
|
.split(";")
|
|
.map((p) => p.trim())
|
|
.filter((p) => !/^domain=/i.test(p))
|
|
.join("; ");
|
|
response.headers.append("set-cookie", cleaned);
|
|
}
|
|
|
|
return response;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "fetch failed";
|
|
errors.push(`${base}: ${message}`);
|
|
}
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: "UPSTREAM_ERROR",
|
|
message: `Master API unreachable: ${errors.join(" | ")}`,
|
|
},
|
|
},
|
|
{ status: 502 }
|
|
);
|
|
}
|
|
|
|
type Ctx = { params: Promise<{ path: string[] }> };
|
|
|
|
export async function GET(request: NextRequest, ctx: Ctx) {
|
|
const { path } = await ctx.params;
|
|
return proxy(request, path);
|
|
}
|
|
|
|
export async function POST(request: NextRequest, ctx: Ctx) {
|
|
const { path } = await ctx.params;
|
|
return proxy(request, path);
|
|
}
|
|
|
|
export async function PUT(request: NextRequest, ctx: Ctx) {
|
|
const { path } = await ctx.params;
|
|
return proxy(request, path);
|
|
}
|
|
|
|
export async function PATCH(request: NextRequest, ctx: Ctx) {
|
|
const { path } = await ctx.params;
|
|
return proxy(request, path);
|
|
}
|
|
|
|
export async function DELETE(request: NextRequest, ctx: Ctx) {
|
|
const { path } = await ctx.params;
|
|
return proxy(request, path);
|
|
}
|