Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
168 lines
6.1 KiB
TypeScript
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 === '/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|.*\\..*).*)'],
|
|
}
|