Ctrl K
ComunidadGuías…

proxy.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { generalRateLimiter, getClientIP, getRateLimitHeaders } from '@/utils/rateLimit';
import { SECURITY_CONFIG } from '@/config/security';
import {
  localeCookieMaxAge,
  localeCookieName,
  resolveLocale,
} from '@/i18n/config';

const PUBLIC_FILE = /^(?!\/\.(?!well-known\/))[^.]*\./;

const PUBLIC_PATHS = [
  '/login',
  '/login/reset',
  '/register',
  '/register/init',
  '/register/terms',
];

function isPublicPath(pathname: string): boolean {
  if (PUBLIC_PATHS.includes(pathname)) {
    return true;
  }
  if (
    pathname.startsWith('/login/otp/') ||
    pathname.startsWith('/login/reset/') ||
    pathname.startsWith('/share/') ||
    pathname.startsWith('/api/v1/share/')
  ) {
    return true;
  }
  return false;
}

export async function proxy(request: NextRequest): Promise<NextResponse> {
  const { pathname } = request.nextUrl;

  // Use internal URL for server-side fetches to avoid SSL errors behind a reverse proxy
  const internalUrl = process.env.INTERNAL_APP_URL ?? request.nextUrl.origin;
  
  // Apply rate limiting to all requests
  const ip = getClientIP(request);
  const rateLimitResult = generalRateLimiter.isAllowed(`${ip}:${pathname}`);
  
  const response = NextResponse.next();
  const locale = resolveLocale(
    request.cookies.get(localeCookieName)?.value,
    request.headers.get('accept-language'),
  );

  if (request.cookies.get(localeCookieName)?.value !== locale) {
    response.cookies.set(localeCookieName, locale, {
      path: '/',
      sameSite: 'lax',
      maxAge: localeCookieMaxAge,
    });
  }
  
// Add security headers from config
  Object.entries(SECURITY_CONFIG.HEADERS).forEach(([key, value]) => {
    if (value) {
      response.headers.set(key, value);
    }
  });
  
  // Add rate limiting headers
  if (!rateLimitResult.allowed) {
    return new NextResponse('Too Many Requests', {
      status: 429,
      headers: {
        ...getRateLimitHeaders(rateLimitResult.resetTime, rateLimitResult.remaining),
        'Content-Type': 'text/plain',
      },
    });
  }
  
Object.entries(getRateLimitHeaders(rateLimitResult.resetTime, rateLimitResult.remaining, SECURITY_CONFIG.RATE_LIMIT.GENERAL.MAX_REQUESTS)).forEach(([key, value]) => {
    response.headers.set(key, value);
  });

  // CORS validation for non-GET requests
  if (request.method !== 'GET') {
    const originHeader = request.headers.get('Origin');
    const hostHeader = request.headers.get('Host');
    
    if (originHeader === null || hostHeader === null) {
      return new NextResponse(null, {
        status: 403,
        headers: response.headers,
      });
    }
    
    let origin: URL;
    try {
      origin = new URL(originHeader);
    } catch {
      return new NextResponse(null, {
        status: 403,
        headers: response.headers,
      });
    }
    
if (origin.host !== hostHeader && !SECURITY_CONFIG.CORS.ALLOWED_ORIGINS.includes(originHeader)) {
      return new NextResponse(null, {
        status: 403,
        headers: response.headers,
      });
    }
    
    // Add CORS headers for valid origins
    if (SECURITY_CONFIG.CORS.ALLOWED_ORIGINS.includes(originHeader)) {
      response.headers.set('Access-Control-Allow-Origin', originHeader);
      response.headers.set('Access-Control-Allow-Methods', SECURITY_CONFIG.CORS.ALLOWED_METHODS.join(','));
      response.headers.set('Access-Control-Allow-Headers', SECURITY_CONFIG.CORS.ALLOWED_HEADERS.join(', '));
      response.headers.set('Access-Control-Allow-Credentials', SECURITY_CONFIG.CORS.ALLOW_CREDENTIALS.toString());
    }
  }

  // Allow static files, Next.js internals and the auth API through
  if (
    pathname.startsWith('/api/v1/auth') ||
    pathname.startsWith('/api/v1/locale') ||
    pathname.startsWith('/_next') ||
    pathname.startsWith('/static') ||
    pathname === '/favicon.ico' ||
    PUBLIC_FILE.test(pathname)
  ) {
    return response;
  }

  // Check authentication for protected routes
  if (!isPublicPath(pathname)) {
    const token = request.cookies.get('session')?.value;
    const sessionResponse = await fetch(
      `${internalUrl}/api/v1/auth/session?token=${token || ''}`,
      {
        cache: 'no-store',
      }
    );
    
    let isLoggedIn = false;
    if (sessionResponse.ok) {
      const sessionData = await sessionResponse.json();
      isLoggedIn = sessionData.user !== null;
      if (isLoggedIn && sessionData.sessionExpiresAt && token) {
        response.cookies.set('session', token, {
          httpOnly: SECURITY_CONFIG.SESSION.COOKIE_HTTP_ONLY,
          sameSite: SECURITY_CONFIG.SESSION.COOKIE_SAME_SITE,
          secure: SECURITY_CONFIG.SESSION.COOKIE_SECURE,
          expires: new Date(sessionData.sessionExpiresAt),
          path: '/',
        });
      }
    }
    
    if (!isLoggedIn) {
      return NextResponse.redirect(
        new URL(`/login?callbackUrl=${encodeURIComponent(pathname)}`, request.url)
      );
    }
  }

  // Validate if admin user exists (for initial registration flow)
  // Allow legal terms during the initial registration flow.
  if (pathname !== '/register/init' && pathname !== '/register/terms') {
    const adminResponse = await fetch(
      `${internalUrl}/api/v1/auth/admin`,
      {
        cache: 'force-cache',
      }
    );
    if (!adminResponse.ok) {
      return response;
    }
    const adminData = await adminResponse.json();
    const adminExists = adminData.initialized;
    if (!adminExists) {
      return NextResponse.redirect(new URL('/register/init', request.url));
    }
  } else {
    const adminResponse = await fetch(
      `${internalUrl}/api/v1/auth/admin`,
      {
        cache: 'force-cache',
      }
    );
    if (!adminResponse.ok) {
      return response;
    }
    const adminData = await adminResponse.json();
    const adminExists = adminData.initialized;
    if (adminExists) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  // Redirect logged-in users away from login page
  if (pathname === '/login') {
    const token = request.cookies.get('session')?.value;

    if (!token || !token.trim() || token === 'undefined') {
      return response;
    }

    const sessionResponse = await fetch(
      `${internalUrl}/api/v1/auth/session?token=${token}`,
      {
        cache: 'no-store',
      }
    );
    if (!sessionResponse.ok) {
      return response;
    }
    const sessionData = await sessionResponse.json();
    const isLoggedIn = sessionData.user !== null;
    if (isLoggedIn) {
      const callbackUrl = request.nextUrl.searchParams.get('callbackUrl');
      const redirectUrl = callbackUrl && callbackUrl.startsWith('/') ? callbackUrl : '/';
      return NextResponse.redirect(new URL(redirectUrl, request.url));
    }
  }

  // Handle registration page - check if registration is allowed
  if (pathname === '/register') {
    const allowResponse = await fetch(
      `${internalUrl}/api/v1/auth/register`
    );
    if (!allowResponse.ok) {
      return response;
    }
    const allowData = await allowResponse.json();
    const allowRegistration = allowData.registration;
    if (!allowRegistration) {
      return NextResponse.redirect(new URL(`/login?callbackUrl=${encodeURIComponent(pathname)}`, request.url));
    }
    return response;
  }

  // Session exists — allow the request
  return response;
}