admin/proxy.ts
alisaza c2bc256103 fix(chat): use Engine.IO path /chat for admin oversight socket
Match consumer/backend so oversight realtime uses wss://host/chat instead
of the default /socket.io path.
2026-09-07 21:49:25 +03:30

168 lines
6.1 KiB
TypeScript

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 === '/chat' || pathname.startsWith('/chat/')) {
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*', '/chat', '/chat/:path*', '/((?!_next|_vercel|.*\\..*).*)'],
}