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 { 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; }