86b06dc417
- api/websocket: capture asynchrone EADDRINUSE pour ne pas bloquer le build - proxy: réécrit aussi les URLs protocol-relatives (//localhost) dans le corps
150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com";
|
|
|
|
async function proxyRequest(req: NextRequest) {
|
|
const host = req.headers.get("host") || "";
|
|
const cleanHost = host.split(":")[0];
|
|
|
|
if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) {
|
|
return NextResponse.json({ error: "Invalid host" }, { status: 404 });
|
|
}
|
|
|
|
const subdomain = cleanHost.replace(`.${MAIN_DOMAIN}`, "");
|
|
|
|
const instance = await prisma.instance.findUnique({
|
|
where: { id: subdomain },
|
|
include: { node: true },
|
|
});
|
|
|
|
if (!instance || !instance.node?.tailscaleIp) {
|
|
return NextResponse.json(
|
|
{ error: "Instance not found or not connected" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
const publicUrl = `https://${cleanHost}`;
|
|
const targetUrl = new URL(req.url);
|
|
// The middleware rewrites /foo to /api/proxy/foo; strip the prefix before forwarding
|
|
let pathname = targetUrl.pathname;
|
|
if (pathname.startsWith("/api/proxy")) {
|
|
pathname = pathname.slice("/api/proxy".length) || "/";
|
|
}
|
|
const upstream = `http://${instance.node.tailscaleIp}:${instance.port}${pathname}${targetUrl.search}`;
|
|
|
|
const headers = new Headers(req.headers);
|
|
headers.delete("host");
|
|
headers.set("host", cleanHost);
|
|
headers.set("x-forwarded-host", cleanHost);
|
|
headers.set("x-forwarded-proto", "https");
|
|
headers.set("x-forwarded-port", "443");
|
|
headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") || "unknown");
|
|
|
|
const upstreamRes = await fetch(upstream, {
|
|
method: req.method,
|
|
headers,
|
|
body:
|
|
req.method !== "GET" && req.method !== "HEAD"
|
|
? (req.body as BodyInit)
|
|
: undefined,
|
|
redirect: "manual",
|
|
});
|
|
|
|
const responseHeaders = new Headers(upstreamRes.headers);
|
|
// Remove content-encoding because Next.js/fetch handles decompression automatically
|
|
responseHeaders.delete("content-encoding");
|
|
responseHeaders.delete("content-length");
|
|
|
|
// Rewrite any header values that point to localhost or the internal Tailscale address
|
|
const localPatterns = [
|
|
`http://${instance.node.tailscaleIp}:${instance.port}`,
|
|
`https://${instance.node.tailscaleIp}:${instance.port}`,
|
|
`http://localhost:${instance.port}`,
|
|
`https://localhost:${instance.port}`,
|
|
`http://localhost`,
|
|
`https://localhost`,
|
|
];
|
|
responseHeaders.forEach((value, key) => {
|
|
let newValue = value;
|
|
for (const pattern of localPatterns) {
|
|
newValue = newValue.replaceAll(pattern, publicUrl);
|
|
}
|
|
if (newValue !== value) {
|
|
responseHeaders.set(key, newValue);
|
|
}
|
|
});
|
|
|
|
// Sanitize Set-Cookie headers so sessions work through the public domain.
|
|
// WordPress may issue cookies for "localhost" or without Secure flag; fix it.
|
|
const setCookies = responseHeaders.getSetCookie();
|
|
if (setCookies.length > 0) {
|
|
responseHeaders.delete("set-cookie");
|
|
for (let cookie of setCookies) {
|
|
// Drop any Domain=... set for localhost/internal domains
|
|
cookie = cookie.replace(/;\s*Domain=[^;]+/gi, "");
|
|
// Ensure Secure is present for HTTPS public URLs
|
|
if (!/;\s*Secure\b/i.test(cookie)) {
|
|
cookie += "; Secure";
|
|
}
|
|
// Make sure cookies are sent on sub-domain navigations
|
|
if (!/;\s*SameSite\b/i.test(cookie)) {
|
|
cookie += "; SameSite=Lax";
|
|
}
|
|
responseHeaders.append("set-cookie", cookie);
|
|
}
|
|
}
|
|
|
|
const contentType = responseHeaders.get("content-type") || "";
|
|
const shouldRewriteBody =
|
|
contentType.includes("text/html") ||
|
|
contentType.includes("text/css") ||
|
|
contentType.includes("application/javascript") ||
|
|
contentType.includes("application/json");
|
|
|
|
if (!shouldRewriteBody) {
|
|
return new Response(upstreamRes.body, {
|
|
status: upstreamRes.status,
|
|
statusText: upstreamRes.statusText,
|
|
headers: responseHeaders,
|
|
});
|
|
}
|
|
|
|
// For text responses, rewrite localhost/internal URLs to the public URL.
|
|
// Also handle protocol-relative URLs that some WordPress plugins/themes use.
|
|
let body = await upstreamRes.text();
|
|
const localBase = `http://${instance.node.tailscaleIp}:${instance.port}`;
|
|
const localBaseHttps = `https://${instance.node.tailscaleIp}:${instance.port}`;
|
|
const localLocalhostHttp = `http://localhost:${instance.port}`;
|
|
const localLocalhostHttps = `https://localhost:${instance.port}`;
|
|
const localLocalhostPlainHttp = `http://localhost`;
|
|
const localLocalhostPlainHttps = `https://localhost`;
|
|
const localLocalhostProtocolRelative = `//localhost`;
|
|
const localTailscaleProtocolRelative = `//${instance.node.tailscaleIp}:${instance.port}`;
|
|
|
|
body = body
|
|
.replaceAll(localBase, publicUrl)
|
|
.replaceAll(localBaseHttps, publicUrl)
|
|
.replaceAll(localLocalhostHttp, publicUrl)
|
|
.replaceAll(localLocalhostHttps, publicUrl)
|
|
.replaceAll(localLocalhostPlainHttp, publicUrl)
|
|
.replaceAll(localLocalhostPlainHttps, publicUrl)
|
|
.replaceAll(localTailscaleProtocolRelative, publicUrl.replace(/^https?:/, ""))
|
|
.replaceAll(localLocalhostProtocolRelative, publicUrl.replace(/^https?:/, ""));
|
|
|
|
return new Response(body, {
|
|
status: upstreamRes.status,
|
|
statusText: upstreamRes.statusText,
|
|
headers: responseHeaders,
|
|
});
|
|
}
|
|
|
|
export const GET = proxyRequest;
|
|
export const POST = proxyRequest;
|
|
export const PUT = proxyRequest;
|
|
export const PATCH = proxyRequest;
|
|
export const DELETE = proxyRequest;
|
|
export const HEAD = proxyRequest;
|
|
export const OPTIONS = proxyRequest;
|