208 lines
6.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 { 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();
}
}