gongxiangp2/middleware.ts

227 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
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))
}
function getAllowedCorsOrigins(request: NextRequest): Set<string> {
const values = [
request.nextUrl.origin,
process.env.APP_PUBLIC_URL,
process.env.NEXTAUTH_URL,
...(process.env.CORS_ALLOWED_ORIGINS || '').split(','),
]
return new Set(values.map((value) => String(value || '').trim().replace(/\/+$/, '')).filter(Boolean))
}
function isAllowedCorsOrigin(request: NextRequest, requestOrigin: string | null): boolean {
if (!requestOrigin) return true
return getAllowedCorsOrigins(request).has(requestOrigin.replace(/\/+$/, ''))
}
// 注意:不在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) {
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*',
],
}