import { NextResponse } from "next/server" import type { NextRequest } from "next/server" import { getToken } from "next-auth/jwt" import { type Permission } from "@/shared/types/permissions" import { resolveDefaultPath } from "@/shared/lib/route-resolver" import { hasPermissionInBitmap } from "@/shared/lib/permission-bitmap" import { SPECIFIC_ROUTE_PERMISSIONS, ROUTE_PREFIX_PERMISSIONS, DASHBOARD_ROUTE_PERMISSIONS, API_ROUTE_PERMISSIONS, } from "@/shared/lib/route-permissions" // Next.js 16 renamed `middleware` to `proxy`. // See: https://nextjs.org/docs/messages/middleware-to-proxy export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl // Skip static assets and auth pages if ( pathname.startsWith("/_next") || pathname.startsWith("/api/auth") || pathname === "/login" || pathname === "/register" || pathname === "/favicon.ico" ) { return NextResponse.next() } const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, }) // Not authenticated → redirect to login if (!token) { const loginUrl = new URL("/login", request.url) loginUrl.searchParams.set("callbackUrl", request.url) return NextResponse.redirect(loginUrl) } // Onboarding gate: 未完成引导的用户只能访问 /onboarding 与白名单路径 // 修复 P2-1:用 middleware 强制重定向替代客户端 Dialog const onboarded = Boolean(token.onboarded) const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/") const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding") if (!onboarded && !isOnboardingPath && !isWhitelistedApi) { const onboardingUrl = new URL("/onboarding", request.url) return NextResponse.redirect(onboardingUrl) } // 已完成 onboarding 的用户不应停留在 /onboarding if (onboarded && isOnboardingPath) { const roles: string[] = (token.roles as string[]) ?? [] const defaultPath = resolveDefaultPath(roles) return NextResponse.redirect(new URL(defaultPath, request.url)) } const permissionsBitmap: string = (token.permissionsBitmap as string) ?? "" const roles: string[] = (token.roles as string[]) ?? [] /** * audit-P1-7:使用位图检查权限,避免每次路由检查都解码完整权限数组。 * proxy.ts 在 edge runtime 运行,每个请求都经过这里,性能至关重要。 */ function hasPermission(requiredPerm: Permission): boolean { return hasPermissionInBitmap(permissionsBitmap, requiredPerm) } // Check API route permissions for (const [prefix, requiredPerm] of Object.entries(API_ROUTE_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!hasPermission(requiredPerm)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } break } } // Check page route permissions // 优先级 1:精确路由权限(覆盖前缀匹配,用于将 /admin/* 下特定页面开放给非管理员) if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname] if (!hasPermission(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } return NextResponse.next() } // 优先级 2:仪表盘路由的细粒度权限(防止跨角色访问仪表盘) if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname] if (!hasPermission(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } return NextResponse.next() } for (const [prefix, requiredPerm] of Object.entries(ROUTE_PREFIX_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!hasPermission(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) // Carry original path + reason in URL so the target page can explain // why the user was redirected (Web Interface Guidelines: URL reflects state). const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } break } } return NextResponse.next() } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }