Fix admin→master connectivity via host fallback; use generic login branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 00:45:53 +02:00
parent 89916d2c0b
commit 0a007aae83
4 changed files with 75 additions and 56 deletions

View file

@ -3,18 +3,21 @@ import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function masterBaseUrl(): string {
return (
process.env.MASTER_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
"http://master-api:3000"
).replace(/\/$/, "");
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(/\/$/, "")))];
}
async function proxy(request: NextRequest, pathSegments: string[]) {
const targetPath = pathSegments.join("/");
const url = new URL(request.url);
const target = `${masterBaseUrl()}/api/${targetPath}${url.search}`;
const search = url.search;
const headers = new Headers();
const contentType = request.headers.get("content-type");
@ -23,52 +26,62 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
if (cookie) headers.set("cookie", cookie);
headers.set("accept", "application/json");
const init: RequestInit = {
method: request.method,
headers,
redirect: "manual",
};
const body =
request.method !== "GET" && request.method !== "HEAD"
? await request.arrayBuffer()
: undefined;
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.arrayBuffer();
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",
});
const responseBody = await upstream.arrayBuffer();
const response = new NextResponse(responseBody, { status: upstream.status });
const upstreamType = upstream.headers.get("content-type");
response.headers.set(
"content-type",
upstreamType ?? "application/json"
);
const anyHeaders = upstream.headers as Headers & {
getSetCookie?: () => string[];
};
const setCookies =
typeof anyHeaders.getSetCookie === "function"
? anyHeaders.getSetCookie()
: upstream.headers.get("set-cookie")
? [upstream.headers.get("set-cookie") as string]
: [];
for (const c of setCookies) {
response.headers.append("set-cookie", c);
}
return response;
} catch (err) {
const message = err instanceof Error ? err.message : "fetch failed";
errors.push(`${base}: ${message}`);
}
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch (err) {
const message = err instanceof Error ? err.message : "Upstream unreachable";
return NextResponse.json(
{ error: { code: "UPSTREAM_ERROR", message: `Master API unreachable: ${message}` } },
{ status: 502 }
);
}
const body = await upstream.arrayBuffer();
const response = new NextResponse(body, { status: upstream.status });
const upstreamType = upstream.headers.get("content-type");
if (upstreamType) {
response.headers.set("content-type", upstreamType);
} else {
response.headers.set("content-type", "application/json");
}
// Forward Set-Cookie so login session works on admin domain
const anyHeaders = upstream.headers as Headers & { getSetCookie?: () => string[] };
const setCookies =
typeof anyHeaders.getSetCookie === "function"
? anyHeaders.getSetCookie()
: upstream.headers.get("set-cookie")
? [upstream.headers.get("set-cookie") as string]
: [];
for (const c of setCookies) {
// Master may set Secure; admin is HTTPS via NPM so keep as-is
response.headers.append("set-cookie", c);
}
return response;
return NextResponse.json(
{
error: {
code: "UPSTREAM_ERROR",
message: `Master API unreachable: ${errors.join(" | ")}`,
},
},
{ status: 502 }
);
}
type Ctx = { params: Promise<{ path: string[] }> };

View file

@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "STH Media Cluster",
description: "Admin dashboard for distributed Stremio media cluster",
title: "Aanmelden",
description: "",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

View file

@ -24,7 +24,7 @@ export function Nav() {
return (
<nav>
<span className="brand">STH Media</span>
<span className="brand">Beheer</span>
{links.map((l) => (
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
{l.label}

View file

@ -62,22 +62,28 @@ services:
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
networks:
- media-cluster
media-cluster:
aliases:
- master-api
admin-ui:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.admin-ui
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
HOSTNAME: 0.0.0.0
PORT: "3000"
# Runtime proxy target (server-side, Docker network)
# Prefer compose DNS; fallback via published host port
MASTER_API_URL: http://master-api:3000
NEXT_PUBLIC_API_URL: http://master-api:3000
depends_on:
- master-api
master-api:
condition: service_healthy
ports:
- "3101:3000"
networks: