import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { jwtVerify } from 'jose'; // 🌍 1. Importamos el motor de idiomas import createIntlMiddleware from 'next-intl/middleware'; import { routing } from './i18n/routing'; // Configuramos el proxy de next-intl const handleI18nRouting = createIntlMiddleware(routing); // 🔒 2. Llave de seguridad del CMS const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"; const encodedKey = new TextEncoder().encode(secretKey); // 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥 export async function proxy(request: NextRequest) { const path = request.nextUrl.pathname; // -------------------------------------------------------- // ZONA ROJA: CENTRO DE MANDO (Seguridad estricta, sin idiomas) // -------------------------------------------------------- if (path.startsWith('/hq-command')) { const isPublicHQRoute = path === '/hq-command/login' || path === '/hq-command/setup'; const cookie = request.cookies.get('flux_session')?.value; // Si intenta entrar al dashboard sin pase if (!isPublicHQRoute && !cookie) { return NextResponse.redirect(new URL('/hq-command/login', request.url)); } // Verificamos el pase if (cookie) { try { await jwtVerify(cookie, encodedKey); // Si tiene pase y está en el login, lo mandamos al dashboard if (isPublicHQRoute) { return NextResponse.redirect(new URL('/hq-command/dashboard', request.url)); } const authedRes = NextResponse.next(); authedRes.headers.set('X-Robots-Tag', 'noindex, nofollow'); return authedRes; } catch (error) { // Pase falso o expirado if (!isPublicHQRoute) { return NextResponse.redirect(new URL('/hq-command/login', request.url)); } } } const hqRes = NextResponse.next(); hqRes.headers.set('X-Robots-Tag', 'noindex, nofollow'); return hqRes; } // -------------------------------------------------------- // ZONA VERDE: WEB PÚBLICA (Motor de Traducciones i18n) // -------------------------------------------------------- // Si el usuario no va al CMS, le pasamos el control a next-intl // para que gestione los prefijos /en/, /it/, /vec/, etc. const response = handleI18nRouting(request); // Belt-and-suspenders fix for the "redirect to :3000" bug behind Nginx. // next-intl can occasionally compose absolute redirect URLs using the // container's internal port (PORT=3000 from the Dockerfile) instead of // the public-facing host. We sanitise any Location header before sending // it back to the browser. const location = response.headers.get('Location'); if (location) { const cleaned = sanitizeRedirectLocation(location, request); if (cleaned !== location) { response.headers.set('Location', cleaned); } } // ── Cache-Control on public pages ──────────────────────────────────── // Marketing pages (home, applications, news, heritage) are dynamic per // request but the rendered HTML is the same for every visitor — no auth // gating, no per-user data. So we let Nginx and the browser cache them // briefly, falling back to a stale copy for up to 5 minutes while a // refresh happens in the background. Result: first hit on a URL renders // freshly (~150-300ms), every subsequent hit within 60s comes from cache // (<10ms). Big perceived-speed boost without breaking ISR semantics. if (isCacheablePublicPath(path) && !hasAuthCookie(request)) { response.headers.set( "Cache-Control", "public, s-maxage=60, stale-while-revalidate=300" ); } return response; } // Pages we're happy to cache at the edge: locale-prefixed marketing routes. // /parts is intentionally excluded — it's the auth-gated B2B portal and its // HTML changes per logged-in user. function isCacheablePublicPath(path: string): boolean { if (path.startsWith("/hq-command")) return false; if (path.startsWith("/api")) return false; if (path.startsWith("/_next")) return false; // Per-locale public pages if (/^\/(en|it|vec|es|de)\/parts(\/|$)/.test(path)) return false; if (/^\/(en|it|vec|es|de)(\/applications|\/news|\/heritage|\/?$)/.test(path)) return true; // Root redirect to /en — also cacheable if (path === "/") return true; return false; } function hasAuthCookie(request: NextRequest): boolean { return Boolean( request.cookies.get("flux_session")?.value || request.cookies.get("flux_b2b_session")?.value ); } function sanitizeRedirectLocation(location: string, request: NextRequest): string { try { const forwardedHost = request.headers.get('x-forwarded-host') || request.headers.get('host'); const forwardedProto = request.headers.get('x-forwarded-proto') || (request.nextUrl.protocol ? request.nextUrl.protocol.replace(':', '') : 'https'); // Relative redirect — leave alone. if (!/^https?:\/\//i.test(location)) return location; const url = new URL(location); const internalPorts = new Set(['3000', '80']); const isInternalHost = url.hostname === '0.0.0.0' || url.hostname === 'localhost' || url.hostname === 'app'; const isInternalPort = url.port && internalPorts.has(url.port); if (forwardedHost && (isInternalHost || isInternalPort)) { const [hostOnly] = forwardedHost.split(':'); url.hostname = hostOnly; url.port = ''; url.protocol = `${forwardedProto}:`; return url.toString(); } // Same host, just a stray internal port — strip it. if (url.port === '3000') { url.port = ''; return url.toString(); } return location; } catch { return location; } } // -------------------------------------------------------- // OPTIMIZACIÓN EXTREMA DEL MATCHER // -------------------------------------------------------- export const config = { // Coincide con TODAS las rutas de la aplicación EXCEPTO: // - /api (nuestras rutas backend) // - /_next/static y /_next/image (archivos internos de React/Next) // - Archivos estáticos como .jpg, .png, .mp4, .glb, .svg (.*\\..*) // IMPORTANTE: Al ignorar los estáticos, las fotos y videos del CMS cargarán rapidísimo. matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)'], };