admin/next.config.js
alisaza 8d2e19ac7d fix(deploy): reject loopback API_PROXY_TARGET in production builds
Prevents baking self-proxy /api and /chat rewrites that pin a CPU core inside Docker.
2026-09-08 14:06:20 +03:30

245 lines
8.5 KiB
JavaScript

const packageJson = require('./package.json')
const { withSentryConfig } = require('@sentry/nextjs')
const isProduction = process.env.NODE_ENV === 'production'
/**
* When the browser talks to the API on a different host than the Next app
* (LAN Nest, or https://ghabilee.ir from localhost), CORS / SameSite cookies
* break. Point NEXT_PUBLIC_API_URL at this Next origin (e.g.
* http://localhost:3008/api/v1) and set API_PROXY_TARGET to the real backend
* so /api/v1 stays same-origin for the browser.
*
* Nest still names/flags the httpOnly refresh cookie from *its* NODE_ENV
* (`__Host-ghabilee_refresh`+Secure in production, `ghabilee_refresh` on
* local HTTP). Rewriting local HTTP Next to production Nest can therefore
* emit a Secure `__Host-` cookie the browser may reject. Prefer local Nest
* when testing login/refresh. See docs/reference/environment-variables.md.
*
* Never point API_PROXY_TARGET at loopback in Docker/production: inside the
* container 127.0.0.1:3000 is this Next process, so /api and /chat rewrites
* become a self-proxy CPU loop. Leave empty when the browser uses a public
* NEXT_PUBLIC_API_URL, or use the backend service hostname on a shared network.
*/
const resolveApiProxyTarget = (raw) => {
const trimmed = (raw || '').trim().replace(/\/+$/, '')
if (!trimmed) return ''
let hostname = ''
try {
hostname = new URL(trimmed).hostname
} catch {
console.warn(`[next.config] Ignoring invalid API_PROXY_TARGET=${JSON.stringify(raw)}`)
return ''
}
const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
if (isLoopback) {
console.warn(
`[next.config] Ignoring loopback API_PROXY_TARGET=${trimmed} (points at this Next process in Docker and causes a CPU loop)`
)
return ''
}
return trimmed
}
const apiProxyTarget = resolveApiProxyTarget(process.env.API_PROXY_TARGET)
/** Path prefix for reverse-proxy deploys (e.g. `/ghabilee`). Empty = site root. */
const rawBasePath = (process.env.NEXT_PUBLIC_BASE_PATH || '').trim()
const basePath = rawBasePath.replace(/\/+$/, '') || undefined
const swScope = basePath ? `${basePath}/` : '/'
// Preserve the search equity of URLs that existed before the public SEO
// routes were introduced. Specific article migrations must come before the
// generic /blogs/:slug rule so removed articles land on the closest useful
// replacement instead of a soft-404.
const retiredCategorySlugRedirects = [
['psychology', 'learning-experience'],
['book-reading', 'conversation-connection'],
['open-conversation', 'conversation-connection'],
['walking', 'sports-adventure'],
['pottery', 'learning-experience'],
['games', 'games-entertainment'],
['educational-workshop', 'learning-experience'],
['art-creativity', 'arts-culture'],
['health-wellness', 'sports-adventure'],
['film-documentary', 'arts-culture'],
['daily-challenges', 'conversation-connection'],
['philosophy-thinking', 'conversation-connection'],
['experience-sharing', 'conversation-connection'],
['futures-studies', 'conversation-connection'],
['board-games', 'games-entertainment'],
['active-team-games', 'games-entertainment'],
['puzzle-mind-games', 'games-entertainment'],
['fun-sensory-games', 'games-entertainment'],
].flatMap(([from, to]) => [
{ source: `/category/${from}`, destination: `/category/${to}`, permanent: true },
{ source: `/category/${from}/:path*`, destination: `/category/${to}/:path*`, permanent: true },
])
const retiredCitySlugRedirects = [
['thran', 'tehran'],
['mshd', 'mashhad'],
['asfhan', 'isfahan'],
['syraz', 'shiraz'],
['tbryz', 'tabriz'],
].flatMap(([from, to]) => [
{ source: `/city/${from}`, destination: `/city/${to}`, permanent: true },
{ source: `/category/:categorySlug/city/${from}`, destination: `/category/:categorySlug/city/${to}`, permanent: true },
])
const legacySeoRedirects = [
{ source: '/karagah-ravanshenasi', destination: '/category/learning-experience', permanent: true },
{ source: '/varzesh-goroohi', destination: '/category/sports-adventure', permanent: true },
{ source: '/honar-va-khalaghiat', destination: '/category/arts-culture', permanent: true },
{ source: '/bazi-va-sargarmi', destination: '/category/games-entertainment', permanent: true },
{ source: '/shabake-sazi-ejtemai', destination: '/category/conversation-connection', permanent: true },
{
source: '/blogs/goroohhaye-piyaderoyi-iran',
destination: '/blog/hiking-outdoor-group-guide',
permanent: true,
},
{ source: '/blogs/mazayaye-karagah-khodshanasi', destination: '/category/learning-experience', permanent: true },
{ source: '/blogs', destination: '/blog', permanent: true },
{ source: '/blogs/:slug', destination: '/blog/:slug', permanent: true },
...retiredCategorySlugRedirects,
...retiredCitySlugRedirects,
]
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
logging: false,
output: 'standalone',
...(basePath ? { basePath, assetPrefix: basePath } : {}),
allowedDevOrigins: ['192.168.100.51', '192.168.100.52'],
transpilePackages: ['mapir-react-component', 'react-mapbox-gl'],
env: {
NEXT_PUBLIC_MAP_API_KEY: process.env.MAP_API_KEY || process.env.NEXT_PUBLIC_MAP_API_KEY || '',
NEXT_PUBLIC_APP_VERSION: packageJson.version,
NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || process.env.VAPID_PUBLIC_KEY || '',
NEXT_PUBLIC_BASE_PATH: basePath || '',
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'ghabilee.ir',
pathname: '/uploads/**',
},
{
protocol: 'https',
hostname: 'cdn.ghabilee.ir',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'file-dev.ghabilee.org',
pathname: '/**',
},
{
protocol: 'http',
hostname: '192.168.100.51',
port: '3000',
pathname: '/**',
},
{
protocol: 'http',
hostname: '65.21.166.55',
pathname: '/**',
},
],
},
async redirects() {
return legacySeoRedirects
},
async rewrites() {
if (!apiProxyTarget) return []
return [
{
source: '/api/v1/:path*',
destination: `${apiProxyTarget}/api/v1/:path*`,
},
{
source: '/chat',
destination: `${apiProxyTarget}/chat`,
},
{
source: '/chat/:path*',
destination: `${apiProxyTarget}/chat/:path*`,
},
]
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self), payment=()' },
...(isProduction ? [{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }] : []),
...(isProduction ? [{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }] : []),
],
},
{
source: '/sw.js',
headers: [
{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' },
{ key: 'Service-Worker-Allowed', value: swScope },
],
},
{
source: '/manifest.webmanifest',
headers: [{ key: 'Cache-Control', value: 'public, max-age=0, must-revalidate' }],
},
]
},
turbopack: {
root: __dirname,
},
}
const sentryAuthToken = (process.env.SENTRY_AUTH_TOKEN || '').trim()
module.exports = withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG || undefined,
project: process.env.SENTRY_PROJECT || undefined,
authToken: sentryAuthToken || undefined,
// Source maps upload only when a CI/build auth token is present.
sourcemaps: {
disable: !sentryAuthToken,
},
silent: true,
// Tunnel through our origin so ad-blockers don't drop browser events.
tunnelRoute: '/monitoring',
widenClientFileUpload: Boolean(sentryAuthToken),
// Replay is intentionally disabled in sentry.client.config.ts. Remove its
// browser-only helpers plus SDK debug statements while preserving tracing.
bundleSizeOptimizations: {
excludeDebugStatements: true,
excludeReplayIframe: true,
excludeReplayShadowDom: true,
excludeReplayWorker: true,
},
webpack: {
treeshake: {
excludeReplayCompressionWorker: true,
excludeReplayIframe: true,
excludeReplayShadowDOM: true,
removeDebugLogging: true,
},
automaticVercelMonitors: false,
},
})