From 0a007aae83523224cab259593cd953acbf85c831 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 00:45:53 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20admin=E2=86=92master=20connectivity=20via?= =?UTF-8?q?=20host=20fallback;=20use=20generic=20login=20branding.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- apps/admin-ui/src/app/api/[...path]/route.ts | 113 +++++++++++-------- apps/admin-ui/src/app/layout.tsx | 4 +- apps/admin-ui/src/components/Nav.tsx | 2 +- deploy/docker/docker-compose.dokploy.yml | 12 +- 4 files changed, 75 insertions(+), 56 deletions(-) diff --git a/apps/admin-ui/src/app/api/[...path]/route.ts b/apps/admin-ui/src/app/api/[...path]/route.ts index ba8776a..7f65d75 100644 --- a/apps/admin-ui/src/app/api/[...path]/route.ts +++ b/apps/admin-ui/src/app/api/[...path]/route.ts @@ -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[] }> }; diff --git a/apps/admin-ui/src/app/layout.tsx b/apps/admin-ui/src/app/layout.tsx index 32d522b..492ba31 100644 --- a/apps/admin-ui/src/app/layout.tsx +++ b/apps/admin-ui/src/app/layout.tsx @@ -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 }) { diff --git a/apps/admin-ui/src/components/Nav.tsx b/apps/admin-ui/src/components/Nav.tsx index 275fa07..bfcf057 100644 --- a/apps/admin-ui/src/components/Nav.tsx +++ b/apps/admin-ui/src/components/Nav.tsx @@ -24,7 +24,7 @@ export function Nav() { return (