import { withSentryConfig } from '@sentry/nextjs'; const cdnDomain = process.env.CDN_DOMAIN || ''; /** * Sentry 客户端 DSN → CSP 放行域。 * 为什么必须在此处理:CSP 是本站安全响应的唯一来源(见下方 APP_SECURITY_HEADERS), * 若不给 DSN 的 ingest 域开 connect-src,浏览器会在 SDK 发 envelope 时直接拦截, * "接线成功但上报被 CSP 拦死"(2026-09-23 验收 final-verdict §4.3 的前置缺口)。 * 做法:仅当 NEXT_PUBLIC_SENTRY_DSN 存在时,从其 URL 中取 protocol//host 追加进 connect-src; * DSN 缺失时 CSP 与接线前逐字节一致(与 GA/监控一致的"无配置即惰性"约定)。 * worker-src 'self' blob: 亦仅在 Sentry 启用时追加:replayIntegration 以 * URL.createObjectURL 生成的 blob worker 运行(证据: * node_modules/@sentry/replay/build/npm/esm/index.js:5221、5637 `new Worker(workerUrl)`), * 默认 worker-src 回落到 default-src 'self' 会拦截 blob:。 */ const sentryPublicDsn = process.env.NEXT_PUBLIC_SENTRY_DSN || ''; let sentryConnectSrcExtra = ''; let sentryWorkerSrcExtra = ''; if (sentryPublicDsn) { try { const dsnUrl = new URL(sentryPublicDsn); sentryConnectSrcExtra = ` ${dsnUrl.protocol}//${dsnUrl.host}`; sentryWorkerSrcExtra = " worker-src 'self' blob:;"; } catch { console.warn('[next.config] NEXT_PUBLIC_SENTRY_DSN 不是合法 URL,CSP 未追加 Sentry 放行域'); } } /** * 应用层安全响应头的唯一来源(single source of truth)。 * * 为什么放在这里而不是 nginx:standalone 产物由 `node dist/standalone/server.js` 提供服务, * 同一份构建会在 nginx 之后、纯 Node、本地 `next start` 等环境下给出完全一致的头部; * 也避免了两层各自 add_header 造成的重复值(历史缺陷:浏览器收到 * `X-Frame-Options: DENY, SAMEORIGIN` 这类非法的重复/互斥值)。 * 覆盖面(官方文档 headers.md):'Headers are checked before the filesystem which includes * pages and /public files.' ⇒ 连 public/uploads/* 也会拿到本数组,无需边缘重复声明。 * * 分工约定(改头部前先对齐): * - next.config.mjs(本数组):随应用版本演进的文档级头部 —— CSP / XFO / nosniff / * Referrer-Policy / Permissions-Policy / X-XSS-Protection / X-DNS-Prefetch-Control。 * - nginx(nginx-static*.conf):只保留 TLS 与 301、HSTS(Next 不设,且只有终结 TLS 的 * 边缘知道协议与证书状态)、gzip、limit_req,以及 /uploads/ 的落盘加固(见下)。 * - 例外:/uploads/ 在 nginx-static-production.conf 中由磁盘直供、Next 不参与应答, * 故该 location 块必须自行声明 nosniff 与 sandbox CSP;这不属于跨层重复。 */ const APP_SECURITY_HEADERS = [ { key: 'X-Content-Type-Options', value: 'nosniff' }, // DENY(而非 nginx 曾设置的 SAMEORIGIN):全仓无以 iframe 嵌入本站页面的用例 // ——src 内所有 iframe 命中都在 src/lib/sanitize.ts 的剔除名单及其测试里(语义为“禁止 iframe”), // e2e 目录零 iframe 引用,管理后台也不以同源 iframe 嵌入任何页面;取更严格值不破坏现有功能。 { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-XSS-Protection', value: '1; mode=block' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Content-Security-Policy', // Sentry 扩展位(sentryConnectSrcExtra / sentryWorkerSrcExtra)仅在有 NEXT_PUBLIC_SENTRY_DSN 时非空, // 其余内容与收紧前逐字节一致(见文件头 APP_SECURITY_HEADERS 注释与 Sentry DSN 段落)。 value: `default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://ssl.google-analytics.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.google-analytics.com https://ssl.google-analytics.com https://www.googletagmanager.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com https://www.googletagmanager.com${sentryConnectSrcExtra}; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';${sentryWorkerSrcExtra}`, }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' }, // 原由 nginx-static.conf 设置;移入应用层,使无 nginx 的环境也带上(Next 官方文档 // 即以 headers() 设置此头:node_modules/next/dist/docs/.../next-config-js/headers.md)。 { key: 'X-DNS-Prefetch-Control', value: 'on' }, ]; const nextConfig = { // 部署层必须按 standalone 契约服务:`node dist/standalone/server.js` + HOSTNAME=0.0.0.0, // 并把 dist/static 拷到 //static、public 拷到 /public // (Next 官方 output 文档:minimal server 默认不带 public 与 static)。 // 见 Dockerfile / Dockerfile.prod;不要把 dist/ 当作静态 HTML 根交给 nginx。 output: 'standalone', // 默认 dist/;可用 NEXT_DIST_DIR 覆盖(CI 分环境输出,或本地需要避开既有构建产物) distDir: process.env.NEXT_DIST_DIR || 'dist', assetPrefix: cdnDomain || undefined, images: { // 关闭 Next 的图片优化(/_next/image 代理 + 服务端重编码),图片以原文件分发。 // 真实原因(与 output 模式无关,standalone 下同样成立): // 1. 图片以 CDN 分发为主(assetPrefix 由 CDN_DOMAIN 驱动,见 docs/CDN_CONFIGURATION.md), // 优化端点的收益被 CDN 抵消; // 2. /_next/image 会替换上游响应,/uploads/ 与 CDN 侧的缓存/Content-Disposition 语义需另行处理。 // 与 sharp 无关:本项目在 src/lib/media/image-processor.ts 里直接用 sharp 生成缩略图/derivative, // 无论本项取何值都需要 musl 平台二进制(见 Dockerfile.prod 的 sharp-deps 阶段)。 // 若要改为 false:必须同时确认所有环境都有 Node 运行时接管 /_next/image(否则图片 404), // 并复核 CDN 回源规则。该改动影响图片服务链路与带宽,属部署决策,勿顺手改。 unoptimized: true, // 注:此处曾声明 formats: ['image/avif', 'image/webp']。unoptimized 为 true 时该字段 // 不参与产物生成(死配置),已移除;恢复图片优化时再按需加回。 }, compress: true, poweredByHeader: false, reactStrictMode: true, experimental: { optimizePackageImports: ['lucide-react', 'framer-motion'], }, compiler: { removeConsole: process.env.NODE_ENV === 'production', }, generateEtags: true, httpAgentOptions: { keepAlive: true, }, async headers() { return [ { source: '/(.*)', headers: APP_SECURITY_HEADERS, }, ]; }, }; /** * withSentryConfig 包装(@sentry/nextjs 10.67.0,导出见 * node_modules/@sentry/nextjs/build/types/index.types.d.ts:27)。它是构建期接线的唯一入口: * - Turbopack 路径(Next 16 默认 bundler,node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:128): * 向 instrumentation 与 instrumentation-client 两个入口文件注入 value-injection 规则 * (node_modules/@sentry/nextjs/build/esm/config/turbopack/generateValueInjectionRules.js:34、49)、 * compiler.runAfterProductionCompile(sourcemaps/发布管理)与 productionBrowserSourceMaps:true * (getFinalConfigObjectBundlerUtils.js:47-120)。 * - 只在 bundler 判定为 webpack 时才追加 custom webpack 键(getFinalConfigObjectBundlerUtils.js:157-159), * 不会触发 Next 16 "Turbopack 构建遇到 webpack 配置即失败" 的保护(version-16.md:154)。 * - 未设 SENTRY_AUTH_TOKEN 时发布/sourcemap 上传仅告警跳过,不会红构建 * (node_modules/@sentry/bundler-plugin-core/dist/cjs/index.js:5743、5992); * 是否启用上传属部署决策,本轮不额外传 options。 * 运行时初始化入口在 src/instrumentation.ts(server/edge)与 src/instrumentation-client.ts(browser), * 无 DSN 时全部惰性(见文件头 Sentry 段落与 sentry.*.config.ts 的 if (SENTRY_DSN) 门禁)。 */ export default withSentryConfig(nextConfig);