8d80cbbc27
- Add `sizes` prop to 8 <Image> components across news, heritage, and
application pages — tells the browser which srcset variant to download,
improving LCP and reducing bandwidth
- Replace date <span> with <time dateTime={ISO}> on news pages —
Google uses datetime for article freshness signals
- Wrap news cards and article content in <article> tags — semantic
boundary for crawlers
- Add X-Robots-Tag: noindex, nofollow header to all /hq-command
responses in proxy.ts — defense-in-depth alongside meta robots
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
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|.*\\..*).*)'],
|
|
}; |