444 lines
11 KiB
JavaScript
444 lines
11 KiB
JavaScript
/** @type {import('next').NextConfig} */
|
||
const isProduction = process.env.NODE_ENV === 'production'
|
||
|
||
function trimToNull(value) {
|
||
const normalized = value?.trim()
|
||
return normalized ? normalized : null
|
||
}
|
||
|
||
function buildUrlOrigin(rawUrl) {
|
||
const normalized = trimToNull(rawUrl)
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
|
||
const withProtocol = /^https?:\/\//i.test(normalized)
|
||
? normalized
|
||
: `https://${normalized}`
|
||
|
||
try {
|
||
const parsed = new URL(withProtocol)
|
||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||
return null
|
||
}
|
||
return parsed.origin
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function collectStorageConnectSources() {
|
||
const origins = new Set()
|
||
for (const candidate of [
|
||
process.env.S3_PUBLIC_DOMAIN,
|
||
process.env.S3_PUBLIC_ENDPOINT,
|
||
process.env.S3_PUBLIC_URL,
|
||
process.env.S3_ENDPOINT,
|
||
process.env.QINIU_PUBLIC_DOMAIN,
|
||
process.env.QINIU_PUBLIC_ENDPOINT,
|
||
process.env.QINIU_PUBLIC_URL,
|
||
]) {
|
||
const origin = buildUrlOrigin(candidate)
|
||
if (origin) {
|
||
origins.add(origin)
|
||
}
|
||
}
|
||
|
||
return Array.from(origins)
|
||
}
|
||
|
||
const connectSources = [
|
||
"'self'",
|
||
'https://login.dingtalk.com',
|
||
'https://api.dingtalk.com',
|
||
'https://oapi.dingtalk.com',
|
||
...collectStorageConnectSources(),
|
||
'ws://127.0.0.1:8000',
|
||
'ws://127.0.0.1:18000',
|
||
'ws://localhost:8000',
|
||
'ws://localhost:18000',
|
||
'wss://localhost.lodop.net:8443',
|
||
...(!isProduction ? ['ws:', 'http://localhost:*', 'http://127.0.0.1:*'] : []),
|
||
]
|
||
|
||
const lodopScriptSources = [
|
||
'http://127.0.0.1:8000',
|
||
'http://127.0.0.1:18000',
|
||
'http://localhost:8000',
|
||
'http://localhost:18000',
|
||
'https://localhost.lodop.net:8443',
|
||
]
|
||
|
||
const contentSecurityPolicy = [
|
||
"default-src 'self'",
|
||
"base-uri 'self'",
|
||
"object-src 'none'",
|
||
"frame-ancestors 'none'",
|
||
`frame-src 'self' ${lodopScriptSources.join(' ')}`,
|
||
"form-action 'self'",
|
||
"img-src 'self' data: blob: http: https:",
|
||
"font-src 'self' data:",
|
||
"style-src 'self' 'unsafe-inline'",
|
||
`script-src 'self' 'unsafe-inline' ${lodopScriptSources.join(' ')}${isProduction ? '' : " 'unsafe-eval'"}`,
|
||
`connect-src ${connectSources.join(' ')}`,
|
||
"worker-src 'self' blob:",
|
||
"media-src 'self' blob:",
|
||
...(isProduction ? ["upgrade-insecure-requests"] : []),
|
||
].join('; ')
|
||
|
||
const baseSecurityHeaders = [
|
||
{
|
||
key: 'X-Frame-Options',
|
||
value: 'DENY'
|
||
},
|
||
{
|
||
key: 'X-Content-Type-Options',
|
||
value: 'nosniff'
|
||
},
|
||
{
|
||
key: 'Referrer-Policy',
|
||
value: 'strict-origin-when-cross-origin'
|
||
},
|
||
{
|
||
key: 'Permissions-Policy',
|
||
value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=()'
|
||
},
|
||
{
|
||
key: 'Cross-Origin-Opener-Policy',
|
||
value: 'same-origin'
|
||
},
|
||
{
|
||
key: 'Cross-Origin-Resource-Policy',
|
||
value: 'same-origin'
|
||
},
|
||
{
|
||
key: 'Content-Security-Policy',
|
||
value: contentSecurityPolicy
|
||
},
|
||
...(isProduction
|
||
? [
|
||
{
|
||
key: 'Strict-Transport-Security',
|
||
value: 'max-age=31536000; includeSubDomains'
|
||
},
|
||
]
|
||
: []),
|
||
]
|
||
|
||
function buildRemotePatternFromUrl(rawUrl) {
|
||
const normalized = trimToNull(rawUrl)
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const withProtocol = /^https?:\/\//i.test(normalized)
|
||
? normalized
|
||
: `https://${normalized}`
|
||
const parsed = new URL(withProtocol)
|
||
const pathname = parsed.pathname.replace(/\/+$/, '')
|
||
return {
|
||
protocol: parsed.protocol.replace(':', ''),
|
||
hostname: parsed.hostname,
|
||
...(parsed.port ? { port: parsed.port } : {}),
|
||
pathname: pathname ? `${pathname}/**` : '/**',
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const remotePatterns = [
|
||
{
|
||
protocol: 'http',
|
||
hostname: 'localhost',
|
||
port: '9000',
|
||
pathname: '/**',
|
||
},
|
||
{
|
||
protocol: 'http',
|
||
hostname: 'minio',
|
||
port: '9000',
|
||
pathname: '/**',
|
||
},
|
||
{
|
||
protocol: 'https',
|
||
hostname: 'threejs.org',
|
||
pathname: '/**',
|
||
},
|
||
]
|
||
|
||
for (const candidate of [
|
||
process.env.S3_PUBLIC_DOMAIN,
|
||
process.env.S3_PUBLIC_ENDPOINT,
|
||
process.env.S3_PUBLIC_URL,
|
||
process.env.S3_ENDPOINT,
|
||
process.env.QINIU_PUBLIC_DOMAIN,
|
||
process.env.QINIU_PUBLIC_ENDPOINT,
|
||
process.env.QINIU_PUBLIC_URL,
|
||
]) {
|
||
const pattern = buildRemotePatternFromUrl(candidate)
|
||
if (!pattern) {
|
||
continue
|
||
}
|
||
|
||
const duplicated = remotePatterns.some(
|
||
(existing) =>
|
||
existing.protocol === pattern.protocol &&
|
||
existing.hostname === pattern.hostname &&
|
||
(existing.port || '') === (pattern.port || '') &&
|
||
existing.pathname === pattern.pathname,
|
||
)
|
||
|
||
if (!duplicated) {
|
||
remotePatterns.push(pattern)
|
||
}
|
||
}
|
||
|
||
function buildClientStorageEnv() {
|
||
return {
|
||
STORAGE_PROVIDER:
|
||
trimToNull(process.env.STORAGE_PROVIDER) ||
|
||
trimToNull(process.env.OBJECT_STORAGE_PROVIDER) ||
|
||
'',
|
||
OBJECT_STORAGE_PROVIDER:
|
||
trimToNull(process.env.OBJECT_STORAGE_PROVIDER) ||
|
||
trimToNull(process.env.STORAGE_PROVIDER) ||
|
||
'',
|
||
STORAGE_FILE_PATH:
|
||
trimToNull(process.env.STORAGE_FILE_PATH) ||
|
||
trimToNull(process.env.OBJECT_STORAGE_FILE_PATH) ||
|
||
'',
|
||
OBJECT_STORAGE_FILE_PATH:
|
||
trimToNull(process.env.OBJECT_STORAGE_FILE_PATH) ||
|
||
trimToNull(process.env.STORAGE_FILE_PATH) ||
|
||
'',
|
||
S3_FILE_PATH: trimToNull(process.env.S3_FILE_PATH) || '',
|
||
QINIU_FILE_PATH: trimToNull(process.env.QINIU_FILE_PATH) || '',
|
||
S3_PUBLIC_DOMAIN: trimToNull(process.env.S3_PUBLIC_DOMAIN) || '',
|
||
S3_PUBLIC_ENDPOINT: trimToNull(process.env.S3_PUBLIC_ENDPOINT) || '',
|
||
S3_PUBLIC_URL: trimToNull(process.env.S3_PUBLIC_URL) || '',
|
||
S3_ENDPOINT: trimToNull(process.env.S3_ENDPOINT) || '',
|
||
S3_BUCKET: trimToNull(process.env.S3_BUCKET) || '',
|
||
QINIU_PUBLIC_DOMAIN: trimToNull(process.env.QINIU_PUBLIC_DOMAIN) || '',
|
||
QINIU_PUBLIC_ENDPOINT: trimToNull(process.env.QINIU_PUBLIC_ENDPOINT) || '',
|
||
QINIU_PUBLIC_URL: trimToNull(process.env.QINIU_PUBLIC_URL) || '',
|
||
QINIU_USE_HTTPS: trimToNull(process.env.QINIU_USE_HTTPS) || '',
|
||
}
|
||
}
|
||
|
||
const clientStorageEnv = buildClientStorageEnv()
|
||
|
||
const nextConfig = {
|
||
distDir: process.env.NEXT_DIST_DIR || '.next',
|
||
// 启用standalone输出,用于Docker部署
|
||
output: 'standalone',
|
||
|
||
// 启用构建时类型检查
|
||
typescript: {
|
||
ignoreBuildErrors: false,
|
||
},
|
||
|
||
// 服务器外部包配置(Next.js 16新配置)
|
||
serverExternalPackages: ['sharp', 'three', 'gsap', '@huggingface/transformers', 'onnxruntime-node', 'mssql'],
|
||
|
||
// Turbopack配置(Next.js 16默认启用)
|
||
turbopack: {},
|
||
|
||
// Keep single request bodies bounded. Large non-integrated files use the chunked upload route.
|
||
experimental: {
|
||
cpus: 2,
|
||
proxyClientMaxBodySize: 500 * 1024 * 1024,
|
||
},
|
||
|
||
// 本地开发允许 localhost 和 127.0.0.1 复用同一套 dev 资源,避免 UI 改动只在其中一个地址生效
|
||
allowedDevOrigins: ['localhost', '127.0.0.1', '192.168.42.134'],
|
||
|
||
// 图像优化配置
|
||
images: {
|
||
remotePatterns,
|
||
unoptimized: true, // 在容器环境中禁用优化以提高性能
|
||
formats: ['image/webp', 'image/avif']
|
||
},
|
||
|
||
// 静态资源配置
|
||
assetPrefix: process.env.NODE_ENV === 'production' ? undefined : undefined,
|
||
|
||
// 压缩配置
|
||
compress: true,
|
||
|
||
// 性能优化
|
||
poweredByHeader: false,
|
||
generateEtags: false,
|
||
|
||
// Webpack配置
|
||
webpack: (config, { dev, isServer }) => {
|
||
// 支持3D文件格式
|
||
config.module.rules.push({
|
||
test: /\.(glb|gltf|fbx|obj|dae)$/,
|
||
type: 'asset/resource',
|
||
generator: {
|
||
filename: 'static/models/[name].[hash][ext]'
|
||
}
|
||
})
|
||
|
||
// 支持音频文件(用于3D场景)
|
||
config.module.rules.push({
|
||
test: /\.(mp3|wav|ogg)$/,
|
||
type: 'asset/resource',
|
||
generator: {
|
||
filename: 'static/audio/[name].[hash][ext]'
|
||
}
|
||
})
|
||
|
||
// 支持着色器文件
|
||
config.module.rules.push({
|
||
test: /\.(glsl|vs|fs|vert|frag)$/,
|
||
type: 'asset/source'
|
||
})
|
||
|
||
// 优化Three.js打包
|
||
if (!isServer) {
|
||
config.resolve.fallback = {
|
||
...config.resolve.fallback,
|
||
fs: false,
|
||
path: false,
|
||
crypto: false
|
||
}
|
||
}
|
||
|
||
// 生产环境优化
|
||
if (!dev) {
|
||
// 启用代码分割
|
||
config.optimization.splitChunks = {
|
||
chunks: 'all',
|
||
cacheGroups: {
|
||
// Three.js单独打包
|
||
three: {
|
||
test: /[\\/]node_modules[\\/](three|@three)[\\/]/,
|
||
name: 'three',
|
||
chunks: 'all',
|
||
priority: 10
|
||
},
|
||
// GSAP单独打包
|
||
gsap: {
|
||
test: /[\\/]node_modules[\\/]gsap[\\/]/,
|
||
name: 'gsap',
|
||
chunks: 'all',
|
||
priority: 10
|
||
},
|
||
// 其他vendor库
|
||
vendor: {
|
||
test: /[\\/]node_modules[\\/]/,
|
||
name: 'vendors',
|
||
chunks: 'all',
|
||
priority: 5
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return config
|
||
},
|
||
|
||
// 环境变量配置
|
||
env: {
|
||
CUSTOM_KEY: process.env.CUSTOM_KEY,
|
||
...clientStorageEnv,
|
||
},
|
||
|
||
// 重定向配置
|
||
async redirects() {
|
||
const redirects = [
|
||
...(!isProduction
|
||
? [
|
||
{
|
||
source: '/:path*',
|
||
has: [
|
||
{
|
||
type: 'host',
|
||
value: '127.0.0.1',
|
||
},
|
||
],
|
||
destination: 'http://localhost:3000/:path*',
|
||
permanent: false,
|
||
},
|
||
]
|
||
: []),
|
||
// 将根路径重定向到主页
|
||
{
|
||
source: '/home',
|
||
destination: '/',
|
||
permanent: true,
|
||
},
|
||
]
|
||
|
||
return redirects
|
||
},
|
||
|
||
// 头部配置(安全性和性能)
|
||
async headers() {
|
||
return [
|
||
{
|
||
source: '/(.*)',
|
||
headers: baseSecurityHeaders,
|
||
},
|
||
{
|
||
// Next 静态资源在生产环境可长期缓存;开发环境必须禁用缓存,否则浏览器会继续复用旧 bundle。
|
||
source: '/_next/static/:path*',
|
||
headers: [
|
||
{
|
||
key: 'Cache-Control',
|
||
value: isProduction
|
||
? 'public, max-age=31536000, immutable'
|
||
: 'no-store, max-age=0, must-revalidate'
|
||
}
|
||
],
|
||
},
|
||
{
|
||
// Draco 解码器文件可长期缓存(静态文件)
|
||
source: '/draco/:path*',
|
||
headers: [
|
||
{
|
||
key: 'Cache-Control',
|
||
value: 'public, max-age=31536000, immutable'
|
||
}
|
||
],
|
||
},
|
||
{
|
||
// 上传资源(模型/图片等)属于静态文件,但文件名未必带 hash,使用较保守的缓存策略
|
||
source: '/uploads/:path*',
|
||
headers: [
|
||
{
|
||
key: 'Cache-Control',
|
||
value: 'public, max-age=86400'
|
||
}
|
||
],
|
||
},
|
||
{
|
||
// 3D模型文件特殊缓存策略
|
||
source: '/models/:path*',
|
||
headers: [
|
||
{
|
||
key: 'Cache-Control',
|
||
value: 'public, max-age=86400, s-maxage=31536000'
|
||
}
|
||
],
|
||
},
|
||
{
|
||
// API 默认仅供同源应用访问,避免被任意外站脚本调用或探测。
|
||
source: '/api/:path*',
|
||
headers: [
|
||
// API 返回的是动态数据,必须禁用缓存,否则会出现“恢复/删除后列表不更新”等问题
|
||
{
|
||
key: 'Cache-Control',
|
||
value: 'no-store'
|
||
}
|
||
],
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
module.exports = nextConfig
|