import { type NextRequest, NextResponse } from 'next/server' import { APP_ROUTES } from '@/constants/routes' import { buildGuestLoginRedirect, getDefaultPostLoginPath, getRoleFromAccessToken, getSafeInternalRedirect, isAdminOnlyRoute, } from '@/lib/authRouting' import { isRequestHostAllowed } from '@/lib/security/requestHost' import { hasRefreshSessionCookie } from '@/lib/refreshSessionCookie' const toOrigin = (value?: string) => { if (!value) return null try { return new URL(value).origin } catch { return null } } const uniqueOrigins = (origins: (string | null)[]) => [...new Set(origins.filter(Boolean))] as string[] /** Dev-only: Next rewrites /api to Nest and forwards Origin. Production Nest rejects localhost Origin. */ const shouldStripForwardedBrowserOrigin = () => process.env.NODE_ENV === 'development' const buildContentSecurityPolicy = (nonce: string, isHttps: boolean) => { const isDev = process.env.NODE_ENV === 'development' const apiOrigin = toOrigin(process.env.NEXT_PUBLIC_API_URL) const fileServerOrigin = toOrigin(process.env.NEXT_PUBLIC_FILE_SERVER_URL) const connectOrigins = uniqueOrigins([apiOrigin, fileServerOrigin]) const imgOrigins = uniqueOrigins([fileServerOrigin, apiOrigin, 'https://file-dev.ghabilee.org', 'https://cdn.ghabilee.ir']) const developmentScriptPolicy = isDev ? " 'unsafe-eval'" : '' const developmentConnectPolicy = isDev ? ' ws:' : '' const arcaptchaOrigins = 'https://*.arcaptcha.ir https://*.arcaptcha.net https://*.arcaptcha.co https://arcaptcha.ir https://arcaptcha.net https://arcaptcha.co https://*.rcap.ir https://*.rcap.ir:* https://jaf.rcap.ir:1443' const imgSrc = isDev ? "img-src 'self' data: blob: http: https:" : `img-src 'self' data: blob: ${imgOrigins.join(' ')} https://map.ir https://*.map.ir ${arcaptchaOrigins}` const mediaSrc = isDev ? "media-src 'self' blob: http: https:" : `media-src 'self' blob: ${imgOrigins.join(' ')}` return [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' ${arcaptchaOrigins}${developmentScriptPolicy}`, `style-src 'self' 'unsafe-inline' ${arcaptchaOrigins}`, imgSrc, mediaSrc, `font-src 'self' data: ${arcaptchaOrigins}`, `connect-src 'self' ${connectOrigins.join(' ')} https://map.ir https://*.map.ir ${arcaptchaOrigins} wss:${developmentConnectPolicy}`, `frame-src 'self' ${arcaptchaOrigins}`, "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", isHttps ? 'upgrade-insecure-requests' : '', ] .join('; ') .replace(/\s+/g, ' ') .trim() } const applyResponseHeaders = (response: NextResponse, policy: string) => { response.headers.set('Content-Security-Policy', policy) // Backoffice is never indexed. response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive') return response } export default function proxy(request: NextRequest) { if (!isRequestHostAllowed(request.headers.get('host'))) { return new NextResponse(null, { status: 421, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex, nofollow, noarchive', }, }) } const { pathname, searchParams } = request.nextUrl if (pathname === '/api' || pathname.startsWith('/api/') || pathname === '/socket.io' || pathname.startsWith('/socket.io/')) { if (!shouldStripForwardedBrowserOrigin()) { return NextResponse.next() } const requestHeaders = new Headers(request.headers) requestHeaders.delete('origin') requestHeaders.delete('referer') return NextResponse.next({ request: { headers: requestHeaders } }) } const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const forwardedProtocol = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() const isHttps = forwardedProtocol ? forwardedProtocol === 'https' : request.nextUrl.protocol === 'https:' const csp = buildContentSecurityPolicy(nonce, isHttps) const requestHeaders = new Headers(request.headers) requestHeaders.set('x-nonce', nonce) requestHeaders.set('Content-Security-Policy', csp) const next = () => applyResponseHeaders(NextResponse.next({ request: { headers: requestHeaders } }), csp) const redirect = (url: URL) => applyResponseHeaders(NextResponse.redirect(url), csp) const accessToken = request.cookies.get('accessToken')?.value const isLoggedIn = Boolean(accessToken || hasRefreshSessionCookie((name) => request.cookies.get(name))) const userStatus = request.cookies.get('userStatus')?.value const isPending = isLoggedIn && userStatus === 'pending' const role = getRoleFromAccessToken(accessToken) const isAuthPath = pathname === '/auth' || pathname.startsWith('/auth/') if (isLoggedIn && isAuthPath) { if (isPending && role === 'admin') { if (searchParams.get('step') !== 'profile') { const profileUrl = new URL('/auth', request.url) profileUrl.searchParams.set('step', 'profile') return redirect(profileUrl) } return next() } // Non-admin sessions do not belong on backoffice. if (role === 'user') { return redirect(new URL('/auth', request.url)) } const redirectTarget = getSafeInternalRedirect(searchParams.get('redirect'), getDefaultPostLoginPath(role)) return redirect(new URL(redirectTarget, request.url)) } if (isPending && role === 'admin' && !isAuthPath) { const profileUrl = new URL('/auth', request.url) profileUrl.searchParams.set('step', 'profile') return redirect(profileUrl) } if (!isLoggedIn && !isAuthPath) { return redirect(buildGuestLoginRedirect(request.url, pathname, request.nextUrl.search)) } if (isLoggedIn && !isPending && role === 'user' && isAdminOnlyRoute(pathname)) { return redirect(new URL('/auth', request.url)) } // Logged-in admin hitting `/` is handled by app/page.tsx redirect to dashboard. if (isLoggedIn && !isPending && role === 'admin' && pathname === '/') { return redirect(new URL(APP_ROUTES.DASHBOARD, request.url)) } return next() } export const config = { matcher: ['/api/:path*', '/socket.io/:path*', '/((?!_next|_vercel|.*\\..*).*)'], }