208 lines
6.5 KiB
TypeScript
208 lines
6.5 KiB
TypeScript
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();
|
||
}
|
||
}
|