import { defaultMode, defaultLocale, isMode, isLocale, localeCookie, localeCookieMaxAgeSeconds, localeFromAcceptLanguage, localizedModePath, routeIdFromAnyLocaleSlug, routeIdFromSlug, } from '@/lib/localization/config'; import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp'; import { isThemePreference, themeCookie, themeCookieMaxAgeSeconds, } from '@/lib/theme/config'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; const dashboardLanguageCookie = 'rentaldrivego-language'; const reservedProxySegments = new Set(['dashboard', 'admin', 'carplace']); function applySecurityHeaders(response: NextResponse, contentSecurityPolicy: string): NextResponse { response.headers.set('Content-Security-Policy', contentSecurityPolicy); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('Cross-Origin-Opener-Policy', 'same-origin'); response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); return response; } function resolveApiOrigin(): string | undefined { const origins = new Set(); const urls = [process.env.API_INTERNAL_URL, process.env.NEXT_PUBLIC_API_URL]; for (const url of urls) { if (!url) continue; try { origins.add(new URL(url).origin); } catch { // ignore malformed URLs } } return origins.size > 0 ? Array.from(origins).join(' ') : undefined; } function resolveRequestLocale(request: NextRequest) { const cookieLocale = request.cookies.get(localeCookie)?.value; return isLocale(cookieLocale) ? cookieLocale : localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale; } function resolveRequestMode(request: NextRequest) { const cookieMode = request.cookies.get(themeCookie)?.value; return isThemePreference(cookieMode) ? cookieMode : defaultMode; } function setLocaleCookie(response: NextResponse, locale: string): void { response.cookies.set(localeCookie, locale, { path: '/', sameSite: 'lax', maxAge: localeCookieMaxAgeSeconds, secure: process.env.NODE_ENV === 'production', httpOnly: false, }); } function setModeCookie(response: NextResponse, mode: string): void { response.cookies.set(themeCookie, mode, { path: '/', sameSite: 'lax', maxAge: themeCookieMaxAgeSeconds, secure: process.env.NODE_ENV === 'production', httpOnly: false, }); } export function proxy(request: NextRequest): NextResponse { const nonce = createNonce(); const isDev = process.env.NODE_ENV !== 'production'; const apiOrigin = resolveApiOrigin(); const csp = buildContentSecurityPolicy(nonce, isDev, apiOrigin); const requestHeaders = new Headers(request.headers); requestHeaders.set('x-nonce', nonce); requestHeaders.set('Content-Security-Policy', csp); if (request.nextUrl.pathname === '/') { const locale = resolveRequestLocale(request); const mode = resolveRequestMode(request); const destination = request.nextUrl.clone(); destination.pathname = `/${locale}/${mode}`; const response = NextResponse.redirect(destination); setLocaleCookie(response, locale); setModeCookie(response, mode); return applySecurityHeaders(response, csp); } const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean); // Redirect locale-less paths (e.g. /sign-in → /en/system/sign-in), but not proxied app paths or files. const firstSegment = request.nextUrl.pathname.split('/')[1]; const isFile = /\.\w+$/.test(request.nextUrl.pathname); if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) { const locale = resolveRequestLocale(request); const mode = resolveRequestMode(request); const destination = request.nextUrl.clone(); destination.pathname = `/${locale}/${mode}${request.nextUrl.pathname}`; return applySecurityHeaders(NextResponse.redirect(destination), csp); } if ( isLocale(pathSegments[0]) && isMode(pathSegments[1]) && request.nextUrl.pathname === `/${pathSegments[0]}/${pathSegments[1]}/subscription` ) { const destination = request.nextUrl.clone(); destination.pathname = '/dashboard/subscription'; const response = NextResponse.redirect(destination); setLocaleCookie(response, pathSegments[0]); setModeCookie(response, pathSegments[1]); response.cookies.set(dashboardLanguageCookie, pathSegments[0], { path: '/', sameSite: 'lax', maxAge: localeCookieMaxAgeSeconds, secure: process.env.NODE_ENV === 'production', httpOnly: false, }); return applySecurityHeaders(response, csp); } if (isLocale(pathSegments[0]) && !isMode(pathSegments[1])) { const mode = resolveRequestMode(request); const slug = pathSegments.slice(1).join('/'); if (!slug) { const destination = request.nextUrl.clone(); destination.pathname = localizedModePath('home', pathSegments[0], mode); return applySecurityHeaders(NextResponse.redirect(destination), csp); } const routeId = routeIdFromSlug(pathSegments[0], slug) ?? routeIdFromAnyLocaleSlug(slug); if (routeId) { const destination = request.nextUrl.clone(); destination.pathname = localizedModePath(routeId, pathSegments[0], mode); return applySecurityHeaders(NextResponse.redirect(destination), csp); } if (slug === 'component-lab') { const destination = request.nextUrl.clone(); destination.pathname = `/${pathSegments[0]}/${mode}/component-lab`; return applySecurityHeaders(NextResponse.redirect(destination), csp); } } if (isLocale(pathSegments[0]) && isMode(pathSegments[1])) { requestHeaders.set('x-theme-preference', pathSegments[1]); const slug = pathSegments.slice(2).join('/'); if (slug && !routeIdFromSlug(pathSegments[0], slug)) { const routeId = routeIdFromAnyLocaleSlug(slug); if (routeId) { const destination = request.nextUrl.clone(); destination.pathname = localizedModePath(routeId, pathSegments[0], pathSegments[1]); return applySecurityHeaders(NextResponse.redirect(destination), csp); } } } const response = NextResponse.next({ request: { headers: requestHeaders } }); const explicitLocale = pathSegments[0]; if (isLocale(explicitLocale)) { setLocaleCookie(response, explicitLocale); } const explicitMode = pathSegments[1]; if (isMode(explicitMode)) { setModeCookie(response, explicitMode); } return applySecurityHeaders(response, csp); } export const config = { matcher: [ { source: '/((?!api|_next/static|_next/image|assets|favicon.ico|robots.txt|sitemap.xml).*)', missing: [ { type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' }, ], }, ], };