Fix admin→master connectivity via host fallback; use generic login branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
89916d2c0b
commit
0a007aae83
4 changed files with 75 additions and 56 deletions
|
|
@ -3,18 +3,21 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
function masterBaseUrl(): string {
|
function candidateBases(): string[] {
|
||||||
return (
|
const configured = [
|
||||||
process.env.MASTER_API_URL ||
|
process.env.MASTER_API_URL,
|
||||||
process.env.NEXT_PUBLIC_API_URL ||
|
process.env.NEXT_PUBLIC_API_URL,
|
||||||
"http://master-api:3000"
|
"http://master-api:3000",
|
||||||
).replace(/\/$/, "");
|
"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[]) {
|
async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
const targetPath = pathSegments.join("/");
|
const targetPath = pathSegments.join("/");
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const target = `${masterBaseUrl()}/api/${targetPath}${url.search}`;
|
const search = url.search;
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const contentType = request.headers.get("content-type");
|
const contentType = request.headers.get("content-type");
|
||||||
|
|
@ -23,39 +26,35 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
if (cookie) headers.set("cookie", cookie);
|
if (cookie) headers.set("cookie", cookie);
|
||||||
headers.set("accept", "application/json");
|
headers.set("accept", "application/json");
|
||||||
|
|
||||||
const init: RequestInit = {
|
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,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
|
body,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
};
|
});
|
||||||
|
|
||||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
const responseBody = await upstream.arrayBuffer();
|
||||||
init.body = await request.arrayBuffer();
|
const response = new NextResponse(responseBody, { status: upstream.status });
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
const upstreamType = upstream.headers.get("content-type");
|
||||||
if (upstreamType) {
|
response.headers.set(
|
||||||
response.headers.set("content-type", upstreamType);
|
"content-type",
|
||||||
} else {
|
upstreamType ?? "application/json"
|
||||||
response.headers.set("content-type", "application/json");
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Forward Set-Cookie so login session works on admin domain
|
const anyHeaders = upstream.headers as Headers & {
|
||||||
const anyHeaders = upstream.headers as Headers & { getSetCookie?: () => string[] };
|
getSetCookie?: () => string[];
|
||||||
|
};
|
||||||
const setCookies =
|
const setCookies =
|
||||||
typeof anyHeaders.getSetCookie === "function"
|
typeof anyHeaders.getSetCookie === "function"
|
||||||
? anyHeaders.getSetCookie()
|
? anyHeaders.getSetCookie()
|
||||||
|
|
@ -64,11 +63,25 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
for (const c of setCookies) {
|
for (const c of setCookies) {
|
||||||
// Master may set Secure; admin is HTTPS via NPM so keep as-is
|
|
||||||
response.headers.append("set-cookie", c);
|
response.headers.append("set-cookie", c);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
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[] }> };
|
type Ctx = { params: Promise<{ path: string[] }> };
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "STH Media Cluster",
|
title: "Aanmelden",
|
||||||
description: "Admin dashboard for distributed Stremio media cluster",
|
description: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export function Nav() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav>
|
<nav>
|
||||||
<span className="brand">STH Media</span>
|
<span className="brand">Beheer</span>
|
||||||
{links.map((l) => (
|
{links.map((l) => (
|
||||||
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
|
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
|
||||||
{l.label}
|
{l.label}
|
||||||
|
|
|
||||||
|
|
@ -62,22 +62,28 @@ services:
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
networks:
|
networks:
|
||||||
- media-cluster
|
media-cluster:
|
||||||
|
aliases:
|
||||||
|
- master-api
|
||||||
|
|
||||||
admin-ui:
|
admin-ui:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: deploy/docker/Dockerfile.admin-ui
|
dockerfile: deploy/docker/Dockerfile.admin-ui
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
# Runtime proxy target (server-side, Docker network)
|
# Prefer compose DNS; fallback via published host port
|
||||||
MASTER_API_URL: http://master-api:3000
|
MASTER_API_URL: http://master-api:3000
|
||||||
NEXT_PUBLIC_API_URL: http://master-api:3000
|
NEXT_PUBLIC_API_URL: http://master-api:3000
|
||||||
depends_on:
|
depends_on:
|
||||||
- master-api
|
master-api:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3101:3000"
|
- "3101:3000"
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue