Files
flux-srl/src/proxy.ts
T
davidherran 8d80cbbc27 perf(seo): image sizes, semantic HTML, X-Robots-Tag headers
- 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>
2026-05-06 18:04:40 -05:00

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|.*\\..*).*)'],
};