gitdataai/admin/src/middleware.ts
ZhenYi fb91f5a6c5 feat(admin): add admin panel with billing alerts and model sync
- Add libs/api/admin with admin API endpoints:
  sync models, workspace credit, billing alert check
- Add workspace_alert_config model and alert service
- Add Session::no_op() for background tasks without user context
- Add admin/ Next.js admin panel (AI models, billing, workspaces, audit)
- Start billing alert background task every 30 minutes
2026-04-19 20:48:59 +08:00

173 lines
5.7 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.

/**
* Next.js Middleware
* 1. 前端路由保护(未登录重定向)
* 2. API 路由权限控制RBAC + API Token
*
* 使用 Node.js Runtime 以支持 bcrypt/ioredis/crypto
*/
export const runtime = "nodejs";
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "crypto";
import { parseSessionCookie, loadAdminSession, canAccess } from "@/lib/auth";
import { query } from "@/lib/db";
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/oidc"];
const PROTECTED_PATHS = ["/dashboard", "/admin", "/platform"];
function getRequiredPermission(path: string, method: string): string | null {
if (path.startsWith("/api/users")) {
if (method === "GET") return "user:read";
if (method === "POST") return "user:create";
if (method === "PUT" || method === "PATCH") return "user:update";
if (method === "DELETE") return "user:delete";
}
if (path.startsWith("/api/roles")) {
if (method === "GET") return "role:read";
if (method === "POST") return "role:create";
if (method === "PUT" || method === "PATCH") return "role:update";
if (method === "DELETE") return "role:delete";
}
if (path.startsWith("/api/permissions")) {
if (method === "GET") return "permission:read";
if (method === "POST") return "permission:create";
if (method === "PUT" || method === "PATCH") return "permission:update";
if (method === "DELETE") return "permission:delete";
}
if (path.startsWith("/api/logs")) {
if (method === "GET") return "log:read";
}
if (path.startsWith("/api/sessions")) {
return "session:manage";
}
if (path.startsWith("/api/platform")) {
if (method === "GET") return "platform:read";
return "platform:manage";
}
if (path.startsWith("/api/admin/projects")) {
if (method === "GET") return "platform:read";
return "platform:manage";
}
if (path.startsWith("/api/api-tokens")) {
return null; // Token 管理需要 session不允许 token 访问自己
}
return null;
}
/** 从 Authorization: Bearer <token> 验证 API Token */
async function verifyApiToken(req: NextRequest): Promise<
{ valid: true; tokenId: number; permissions: string[] } |
{ valid: false }
> {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) return { valid: false };
const token = authHeader.slice("Bearer ".length);
if (!token) return { valid: false };
try {
const hash = createHash("sha256").update(token).digest("hex");
const result = await query<{
id: number;
permissions: string[];
is_active: boolean;
expires_at: Date | null;
}>(
`SELECT id, permissions, is_active, expires_at FROM admin_api_token WHERE token_hash = $1`,
[hash]
);
if (!result.rows.length) return { valid: false };
const row = result.rows[0];
if (!row.is_active) return { valid: false };
if (row.expires_at && new Date(row.expires_at) < new Date()) return { valid: false };
// 更新 last_used_atfire-and-forget
query(`UPDATE admin_api_token SET last_used_at = NOW() WHERE id = $1`, [row.id]).catch(() => {});
return { valid: true, tokenId: row.id, permissions: row.permissions || [] };
} catch {
return { valid: false };
}
}
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const method = req.method;
// 静态资源跳过
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.endsWith(".ico") ||
pathname.endsWith(".png") ||
pathname.endsWith(".svg")
) {
return NextResponse.next();
}
// 公开路由放行
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// ========== API 路由权限控制 ==========
if (pathname.startsWith("/api/")) {
const headers = new Headers(req.headers);
let authType: "session" | "token" = "session";
let permissions: string[] = [];
// 优先尝试 API Token 认证
const tokenResult = await verifyApiToken(req);
if (tokenResult.valid) {
authType = "token";
permissions = tokenResult.permissions || [];
headers.set("x-admin-auth-type", "token");
headers.set("x-admin-token-id", String(tokenResult.tokenId));
} else {
// 回退到 Session 认证
const cookieHeader = req.headers.get("cookie");
const sessionId = parseSessionCookie(cookieHeader);
if (!sessionId) {
return NextResponse.json({ error: "未登录或未提供 API Token" }, { status: 401 });
}
const session = await loadAdminSession(sessionId);
if (!session) {
return NextResponse.json({ error: "会话已过期" }, { status: 401 });
}
headers.set("x-admin-user-id", String(session.userId));
headers.set("x-admin-username", session.username);
headers.set("x-admin-permissions", session.permissions.join(","));
permissions = session.permissions;
}
const requiredPermission = getRequiredPermission(pathname, method);
if (requiredPermission && !permissions.some((p) => p === "*" || p === requiredPermission)) {
return NextResponse.json({ error: "权限不足" }, { status: 403 });
}
return NextResponse.next({ request: { headers } });
}
// ========== 前端页面保护(仅 Session ==========
if (PROTECTED_PATHS.some((p) => pathname.startsWith(p))) {
const cookieHeader = req.headers.get("cookie");
const sessionCookie = cookieHeader
?.split(";")
.find((c) => c.trim().startsWith("admin_session="));
if (!sessionCookie) {
return NextResponse.redirect(new URL("/login", req.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};