Files
edubox/server/app/api/proxy/[[...path]]/route.ts
T
EduBox Dev 86b06dc417 fix(server): gestion erreur WebSocket au build + rewrite URL protocol-relative dans le proxy
- api/websocket: capture asynchrone EADDRINUSE pour ne pas bloquer le build
- proxy: réécrit aussi les URLs protocol-relatives (//localhost) dans le corps
2026-06-17 18:27:26 +00:00

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;