219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
import { NextResponse } from 'next/server'
|
||
import type { NextRequest } from 'next/server'
|
||
import { getToken } from 'next-auth/jwt'
|
||
import {
|
||
getAuthSessionCookieCandidates,
|
||
resolveSecureAuthCookieFromRequest,
|
||
} from '@/lib/auth-cookie'
|
||
import { shouldRedirectToStandaloneMaterialLibrary } from '@/lib/brand-center-access-policy'
|
||
import { getAllowedCorsOrigins, isAllowedCorsOrigin } from '@/lib/cors-origin'
|
||
|
||
const API_CORS_ALLOWED_HEADERS =
|
||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie'
|
||
|
||
const DEPARTMENT_PAGE_PREFIXES = [
|
||
'/assets/material-library',
|
||
'/workspaces',
|
||
]
|
||
|
||
function isDepartmentPage(pathname: string): boolean {
|
||
return pathname === '/' || DEPARTMENT_PAGE_PREFIXES.some((prefix) => pathname.startsWith(prefix))
|
||
}
|
||
|
||
// 注意:不在middleware中启动内存监控
|
||
// middleware使用Edge Runtime,不支持Node.js API
|
||
// 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境)
|
||
|
||
export async function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl
|
||
const requestOrigin = request.headers.get('origin')
|
||
const isAuthEntryPage =
|
||
pathname === '/login' ||
|
||
pathname === '/dingtalk-entry' ||
|
||
pathname === '/dingtalk-callback' ||
|
||
pathname.startsWith('/performance-survey')
|
||
|
||
// 处理API路由的CORS
|
||
if (pathname.startsWith('/api/')) {
|
||
const corsOriginAllowed = isAllowedCorsOrigin(request, requestOrigin)
|
||
if (!corsOriginAllowed) {
|
||
console.warn('[middleware] blocked api origin', {
|
||
pathname,
|
||
requestOrigin,
|
||
requestHostOrigin: request.headers.get('host') || request.headers.get('x-forwarded-host') || '',
|
||
requestProto: request.headers.get('x-forwarded-proto') || request.nextUrl.protocol || '',
|
||
allowedOrigins: Array.from(getAllowedCorsOrigins(request)),
|
||
})
|
||
return NextResponse.json({ error: '不允许的跨域来源' }, { status: 403 })
|
||
}
|
||
|
||
// 处理OPTIONS预检请求
|
||
if (request.method === 'OPTIONS') {
|
||
const preflightResponse = new NextResponse(null, {
|
||
status: 204,
|
||
headers: {
|
||
'Access-Control-Allow-Methods': 'GET,DELETE,PATCH,POST,PUT,OPTIONS',
|
||
'Access-Control-Allow-Headers': API_CORS_ALLOWED_HEADERS,
|
||
'Access-Control-Max-Age': '86400',
|
||
'Vary': 'Origin',
|
||
},
|
||
})
|
||
if (requestOrigin) {
|
||
preflightResponse.headers.set('Access-Control-Allow-Credentials', 'true')
|
||
preflightResponse.headers.set('Access-Control-Allow-Origin', requestOrigin)
|
||
}
|
||
return preflightResponse
|
||
}
|
||
|
||
// 为所有API响应添加CORS头
|
||
const response = NextResponse.next()
|
||
response.headers.set('Vary', 'Origin')
|
||
if (requestOrigin) {
|
||
response.headers.set('Access-Control-Allow-Credentials', 'true')
|
||
response.headers.set('Access-Control-Allow-Origin', requestOrigin)
|
||
}
|
||
response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS')
|
||
response.headers.set('Access-Control-Allow-Headers', API_CORS_ALLOWED_HEADERS)
|
||
|
||
return response
|
||
}
|
||
|
||
// 处理需要认证的页面
|
||
// /materials(素材库)与 /organization(组织管理)与首页同级,需登录;
|
||
// /organization 沿用管理员角色门(与 /admin 一致)
|
||
if (
|
||
isDepartmentPage(pathname) ||
|
||
pathname === '/materials' ||
|
||
pathname.startsWith('/admin') ||
|
||
pathname.startsWith('/organization') ||
|
||
pathname.startsWith('/performance-survey') ||
|
||
pathname.startsWith('/work-review') ||
|
||
pathname.startsWith('/teacher')
|
||
) {
|
||
const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request)
|
||
const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie)
|
||
let token = null
|
||
|
||
for (const cookieName of cookieCandidates) {
|
||
token = await getToken({
|
||
req: request,
|
||
secret: process.env.NEXTAUTH_SECRET,
|
||
cookieName,
|
||
secureCookie: cookieName.startsWith('__Secure-'),
|
||
})
|
||
|
||
if (token) {
|
||
break
|
||
}
|
||
}
|
||
|
||
if (!token) {
|
||
const url = new URL('/login', request.url)
|
||
const redirectResponse = NextResponse.redirect(url)
|
||
// 为重定向响应也添加缓存控制头
|
||
redirectResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
|
||
redirectResponse.headers.set('Pragma', 'no-cache')
|
||
redirectResponse.headers.set('Expires', '0')
|
||
return redirectResponse
|
||
}
|
||
|
||
if (shouldRedirectToStandaloneMaterialLibrary(pathname, (token as any).canAccessBrandCenter)) {
|
||
const url = new URL('/assets/material-library', request.url)
|
||
const redirectResponse = NextResponse.redirect(url)
|
||
redirectResponse.headers.set('Cache-Control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
|
||
redirectResponse.headers.set('Pragma', 'no-cache')
|
||
redirectResponse.headers.set('Expires', '0')
|
||
return redirectResponse
|
||
}
|
||
|
||
// 管理员页面需要管理员权限
|
||
if (pathname.startsWith('/admin') || pathname.startsWith('/organization')) {
|
||
const role = String((token as any)?.role || '')
|
||
if (!['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)) {
|
||
const url = new URL('/', request.url)
|
||
const redirectResponse = NextResponse.redirect(url)
|
||
// 为重定向响应也添加缓存控制头
|
||
redirectResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
|
||
redirectResponse.headers.set('Pragma', 'no-cache')
|
||
redirectResponse.headers.set('Expires', '0')
|
||
return redirectResponse
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否有新版本部署(通过环境变量控制)
|
||
// 只有在部署新版本时才禁用缓存,否则允许浏览器缓存以提高性能
|
||
const disableCache = process.env.DISABLE_CACHE === 'true'
|
||
const deploymentVersion = process.env.DEPLOYMENT_VERSION || process.env.BUILD_ID || ''
|
||
|
||
// 获取客户端存储的版本号(通过 cookie)
|
||
const clientVersion = request.cookies.get('app-version')?.value || ''
|
||
|
||
// 判断是否需要禁用缓存:有新版本部署且客户端版本与服务器版本不同
|
||
const shouldDisableCache = disableCache && deploymentVersion && clientVersion !== deploymentVersion
|
||
|
||
const response = NextResponse.next()
|
||
|
||
// 如果检测到新版本,设置版本号 cookie
|
||
if (shouldDisableCache && deploymentVersion) {
|
||
response.cookies.set('app-version', deploymentVersion, {
|
||
path: '/',
|
||
maxAge: 60 * 60 * 24 * 365, // 1年
|
||
sameSite: 'lax',
|
||
})
|
||
}
|
||
|
||
// 对于 HTML 页面,根据是否有新版本决定缓存策略
|
||
if (
|
||
isDepartmentPage(pathname) ||
|
||
pathname === '/materials' ||
|
||
pathname.startsWith('/admin') ||
|
||
pathname.startsWith('/organization') ||
|
||
pathname.startsWith('/share') ||
|
||
pathname.startsWith('/edit') ||
|
||
isAuthEntryPage ||
|
||
pathname.endsWith('.html')
|
||
) {
|
||
if (isDepartmentPage(pathname) || pathname.startsWith('/admin') || isAuthEntryPage || shouldDisableCache) {
|
||
// 认证页面或有新版本时禁用缓存,避免用户范围数据被共享缓存
|
||
response.headers.set('Cache-Control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
|
||
response.headers.set('Pragma', 'no-cache')
|
||
response.headers.set('Expires', '0')
|
||
} else {
|
||
// 无新版本:允许浏览器缓存,提高性能
|
||
response.headers.set('Cache-Control', 'public, max-age=3600, must-revalidate') // 缓存1小时
|
||
}
|
||
}
|
||
|
||
// 对于 JS/CSS 等静态资源,使用版本号或时间戳来强制更新
|
||
if (pathname.match(/\.(js|css|json|ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/)) {
|
||
if (shouldDisableCache) {
|
||
// 有新版本:禁用缓存
|
||
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
|
||
} else {
|
||
// 无新版本:长期缓存(Next.js 会自动添加版本号到文件名)
|
||
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable')
|
||
}
|
||
}
|
||
|
||
return response
|
||
}
|
||
|
||
export const config = {
|
||
matcher: [
|
||
'/',
|
||
'/assets/material-library/:path*',
|
||
'/workspaces/:path*',
|
||
'/login',
|
||
'/materials',
|
||
'/organization/:path*',
|
||
'/admin/:path*',
|
||
'/api/:path*',
|
||
'/dingtalk-entry',
|
||
'/dingtalk-callback',
|
||
'/performance-survey/:path*',
|
||
'/work-review/:path*',
|
||
'/teacher/:path*',
|
||
],
|
||
}
|