stremio/apps/admin-ui/src/app/api/[...path]/route.ts
Jos Vooges | STH 39ecf1af56 Fix admin session cookie by setting it on the admin domain after login.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:00:16 +02:00

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);
}