import { desc, eq, sql } from "drizzle-orm"; import { NextResponse } from "next/server"; import { z } from "zod"; import { db } from "@/lib/db"; import { accessIpBlocklist, user } from "@/lib/db/schema"; import { ChatSDKError } from "@/lib/errors"; import { requireAdmin } from "@/lib/middleware/admin"; import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log"; import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist"; export const runtime = "nodejs"; export const maxDuration = 10; const createSchema = z.object({ ip: z.string().trim().min(1).max(128), reason: z.string().trim().max(500).optional().nullable(), expiresAt: z.string().trim().optional().nullable(), }); const patchSchema = z.object({ id: z.string().uuid(), isActive: z.boolean(), reason: z.string().trim().max(500).optional().nullable(), expiresAt: z.string().trim().optional().nullable(), }); function normalizeOptionalDate(value?: string | null) { const normalized = String(value || "").trim(); if (!normalized) return null; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? null : date; } export async function GET() { try { await requireAdmin(); const entries = await db .select({ id: accessIpBlocklist.id, ip: accessIpBlocklist.ip, reason: accessIpBlocklist.reason, isActive: accessIpBlocklist.isActive, expiresAt: accessIpBlocklist.expiresAt, createdAt: accessIpBlocklist.createdAt, updatedAt: accessIpBlocklist.updatedAt, createdBy: accessIpBlocklist.createdBy, createdByEmail: user.email, }) .from(accessIpBlocklist) .leftJoin(user, eq(accessIpBlocklist.createdBy, user.id)) .orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt)) .limit(300); return NextResponse.json({ entries }); } catch (error) { if (error instanceof ChatSDKError) { return error.toResponse(); } console.error("[AccessIpBlocklist] failed to list entries", error); return new ChatSDKError("offline:admin").toResponse(); } } export async function POST(request: Request) { try { const session = await requireAdmin(); const body = createSchema.parse(await request.json()); const normalizedIp = normalizeManualBlockIp(body.ip); if (!normalizedIp) { return new ChatSDKError( "bad_request:api", "请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24" ).toResponse(); } const now = new Date(); const expiresAt = normalizeOptionalDate(body.expiresAt); const reason = body.reason?.trim() || null; const [existing] = await db .select({ id: accessIpBlocklist.id }) .from(accessIpBlocklist) .where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`) .limit(1); const [entry] = existing ? await db .update(accessIpBlocklist) .set({ reason, isActive: true, expiresAt, updatedBy: session.user.id, updatedAt: now, }) .where(eq(accessIpBlocklist.id, existing.id)) .returning() : await db .insert(accessIpBlocklist) .values({ ip: normalizedIp, reason, isActive: true, expiresAt, createdBy: session.user.id, updatedBy: session.user.id, createdAt: now, updatedAt: now, }) .returning(); await recordAccessIpEventFromHeaders({ eventType: "manual_block_added", userId: session.user.id, userEmail: session.user.email || null, userRole: session.user.type, method: "POST", path: "/api/admin/access-blocklist", routeGroup: "admin-api", statusCode: 200, outcome: "manual_block_added", severity: "high", reasons: ["管理员手动拉黑 IP"], metadata: { targetIp: normalizedIp, reason, }, }).catch((error) => { console.warn("[AccessIpBlocklist] failed to record block action", error); }); return NextResponse.json({ entry }); } catch (error) { if (error instanceof ChatSDKError) { return error.toResponse(); } if (error instanceof z.ZodError) { return new ChatSDKError("bad_request:api", error.message).toResponse(); } console.error("[AccessIpBlocklist] failed to create entry", error); return new ChatSDKError("offline:admin").toResponse(); } } export async function PATCH(request: Request) { try { const session = await requireAdmin(); const body = patchSchema.parse(await request.json()); const now = new Date(); const expiresAt = normalizeOptionalDate(body.expiresAt); const [existing] = await db .select({ ip: accessIpBlocklist.ip }) .from(accessIpBlocklist) .where(eq(accessIpBlocklist.id, body.id)) .limit(1); if (!existing) { return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse(); } const [entry] = await db .update(accessIpBlocklist) .set({ isActive: body.isActive, reason: body.reason?.trim() || undefined, expiresAt, updatedBy: session.user.id, updatedAt: now, }) .where(eq(accessIpBlocklist.id, body.id)) .returning(); await recordAccessIpEventFromHeaders({ eventType: body.isActive ? "manual_block_enabled" : "manual_block_released", userId: session.user.id, userEmail: session.user.email || null, userRole: session.user.type, method: "PATCH", path: "/api/admin/access-blocklist", routeGroup: "admin-api", statusCode: 200, outcome: body.isActive ? "manual_block_enabled" : "manual_block_released", severity: body.isActive ? "high" : "watch", reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"], metadata: { targetIp: existing.ip, blocklistId: body.id, }, }).catch((error) => { console.warn("[AccessIpBlocklist] failed to record update action", error); }); return NextResponse.json({ entry }); } catch (error) { if (error instanceof ChatSDKError) { return error.toResponse(); } if (error instanceof z.ZodError) { return new ChatSDKError("bad_request:api", error.message).toResponse(); } console.error("[AccessIpBlocklist] failed to update entry", error); return new ChatSDKError("offline:admin").toResponse(); } }