diff --git a/app/api/github/callback/route.ts b/app/api/github/callback/route.ts index 6543e08b..8049d1ed 100644 --- a/app/api/github/callback/route.ts +++ b/app/api/github/callback/route.ts @@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { getInstallation } from "@/lib/github/app"; import { upsertInstallation } from "@/lib/github/installations"; +import { publicUrl } from "@/lib/request-url"; export const runtime = "nodejs"; @@ -23,7 +24,7 @@ export async function GET(request: NextRequest) { if (!installationId) { return NextResponse.redirect( - new URL(`${SETTINGS_URL}?error=missing_installation_id`, request.url), + publicUrl(request.headers, `${SETTINGS_URL}?error=missing_installation_id`), ); } @@ -36,7 +37,7 @@ export async function GET(request: NextRequest) { // Park them at login with this callback as the redirect. const back = `${SETTINGS_URL}?installation_id=${installationId}`; return NextResponse.redirect( - new URL(`/login?redirect=${encodeURIComponent(back)}`, request.url), + publicUrl(request.headers, `/login?redirect=${encodeURIComponent(back)}`), ); } @@ -44,7 +45,7 @@ export async function GET(request: NextRequest) { // don't own — there's nothing for us to persist yet. if (setupAction === "request") { return NextResponse.redirect( - new URL(`${SETTINGS_URL}?notice=install_requested`, request.url), + publicUrl(request.headers, `${SETTINGS_URL}?notice=install_requested`), ); } @@ -60,14 +61,14 @@ export async function GET(request: NextRequest) { } catch (err) { const msg = err instanceof Error ? err.message : "unknown"; return NextResponse.redirect( - new URL( + publicUrl( + request.headers, `${SETTINGS_URL}?error=${encodeURIComponent("install_callback:" + msg)}`, - request.url, ), ); } return NextResponse.redirect( - new URL(`${SETTINGS_URL}?connected=1`, request.url), + publicUrl(request.headers, `${SETTINGS_URL}?connected=1`), ); } diff --git a/app/api/github/setup-callback/route.ts b/app/api/github/setup-callback/route.ts index ccba5a69..e9099aa2 100644 --- a/app/api/github/setup-callback/route.ts +++ b/app/api/github/setup-callback/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import { convertAppManifest } from "@/lib/github/app"; +import { publicUrl } from "@/lib/request-url"; export const runtime = "nodejs"; @@ -13,10 +14,7 @@ export async function GET(request: NextRequest) { const code = new URL(request.url).searchParams.get("code"); if (!code) { return NextResponse.redirect( - new URL( - "/admin/github/setup?error=missing_code", - request.url, - ), + publicUrl(request.headers, "/admin/github/setup?error=missing_code"), ); } try { @@ -35,17 +33,14 @@ export async function GET(request: NextRequest) { owner: result.owner.login, }); return NextResponse.redirect( - new URL( - `/admin/github/setup/done#${params.toString()}`, - request.url, - ), + publicUrl(request.headers, `/admin/github/setup/done#${params.toString()}`), ); } catch (err) { const msg = err instanceof Error ? err.message : "unknown"; return NextResponse.redirect( - new URL( + publicUrl( + request.headers, `/admin/github/setup?error=${encodeURIComponent(msg)}`, - request.url, ), ); } diff --git a/lib/request-url.ts b/lib/request-url.ts new file mode 100644 index 00000000..f4cb5943 --- /dev/null +++ b/lib/request-url.ts @@ -0,0 +1,47 @@ +// Behind Railway's reverse proxy, Next.js's `request.url` reports the +// internal bind address (e.g. http://0.0.0.0:8080/...) instead of the +// public URL. That's correct per HTTP semantics — the Host header IS +// 0.0.0.0:8080 from the upstream perspective — but it breaks any code +// that uses `request.url` to build a public Location header on a +// redirect, because the browser then gets sent to the bind address. +// +// This helper picks the correct base by preferring x-forwarded-host + +// x-forwarded-proto (the values Railway's proxy adds), falling back to +// the raw Host header. It ignores 0.0.0.0 / 127.0.0.1 hostnames so they +// can never get baked into a Location header. + +import { env } from "@/lib/env"; + +interface HeaderSource { + get(name: string): string | null; +} + +export function publicBaseUrlFromHeaders(headers: HeaderSource): string { + const fwdHost = headers.get("x-forwarded-host"); + const fwdProto = headers.get("x-forwarded-proto"); + const rawHost = headers.get("host"); + + const candidate = fwdHost ?? rawHost; + const proto = fwdProto ?? "https"; + + if ( + candidate && + !candidate.startsWith("0.0.0.0") && + !candidate.startsWith("127.0.0.1") && + !candidate.startsWith("localhost") + ) { + return `${proto}://${candidate}`.replace(/\/$/, ""); + } + // Last-ditch fallback to the env (dev case where headers are sparse). + return env.siteUrl.replace(/\/$/, ""); +} + +/** Build an absolute URL anchored at the public base. Use this instead + * of `new URL(path, request.url)` when constructing redirect Location + * values — `request.url` carries the internal host behind a proxy. */ +export function publicUrl(headers: HeaderSource, path: string): string { + const base = publicBaseUrlFromHeaders(headers); + // path may include a fragment (#...) — preserve verbatim, don't pass + // through URL() which would percent-encode the fragment payload. + return `${base}${path.startsWith("/") ? "" : "/"}${path}`; +}