import { defaultLocale, isLocale, localeCookie, localeCookieMaxAgeSeconds, localeFromAcceptLanguage, } from '@/lib/localization/config'; import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; 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; } 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 cookieLocale = request.cookies.get(localeCookie)?.value; const locale = isLocale(cookieLocale) ? cookieLocale : localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale; const destination = request.nextUrl.clone(); destination.pathname = `/${locale}`; const response = NextResponse.redirect(destination); response.cookies.set(localeCookie, locale, { path: '/', sameSite: 'lax', maxAge: localeCookieMaxAgeSeconds, secure: process.env.NODE_ENV === 'production', httpOnly: false, }); return applySecurityHeaders(response, csp); } // Redirect locale-less paths (e.g. /sign-in → /en/sign-in), but not /dashboard, /admin, or files const firstSegment = request.nextUrl.pathname.split('/')[1]; const isFile = /\.\w+$/.test(request.nextUrl.pathname); if (firstSegment && !isFile && !isLocale(firstSegment) && firstSegment !== 'dashboard' && firstSegment !== 'admin') { const cookieLocale = request.cookies.get(localeCookie)?.value; const locale = isLocale(cookieLocale) ? cookieLocale : localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale; const destination = request.nextUrl.clone(); destination.pathname = `/${locale}${request.nextUrl.pathname}`; return applySecurityHeaders(NextResponse.redirect(destination), csp); } const response = NextResponse.next({ request: { headers: requestHeaders } }); const explicitLocale = request.nextUrl.pathname.split('/')[1]; if (isLocale(explicitLocale)) { response.cookies.set(localeCookie, explicitLocale, { path: '/', sameSite: 'lax', maxAge: localeCookieMaxAgeSeconds, secure: process.env.NODE_ENV === 'production', httpOnly: false, }); } 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' }, ], }, ], };