Files
edubox/server/app/api/proxy/[[...path]]/route.ts
T
EduBox Dev a94b7526f7 fix(server/proxy): ajout duplex half pour forwarder les POST
- Route proxy: ajout de duplex: 'half' lors de l'envoi d'un body,
  requis par Node.js fetch pour éviter l'erreur 500 sur wp-login.php
2026-06-17 18:55:50 +00:00

150 lines
5.5 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 needsBody = req.method !== "GET" && req.method !== "HEAD";
const upstreamRes = await fetch(upstream, {
method: req.method,
headers,
body: needsBody ? (req.body as BodyInit) : undefined,
// Node.js fetch requires duplex when forwarding a request body stream
...(needsBody ? { duplex: "half" } : {}),
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;