141 lines
4.1 KiB
TypeScript

import { and, eq, gt, isNull, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { accessIpBlocklist } from "@/lib/db/schema";
import { matchManualIpBlock } from "@/lib/security/manual-ip-blocklist";
export const runtime = "nodejs";
export const maxDuration = 5;
const payloadSchema = z.object({
ip: z.string().trim().max(128).optional().nullable(),
});
const BLOCKLIST_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
let blocklistUnavailableUntil = 0;
let blocklistMissingTableWarned = false;
function getExpectedSecret() {
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
}
function getErrorCode(error: unknown) {
const directCode =
error && typeof error === "object"
? (error as { code?: unknown }).code
: null;
if (typeof directCode === "string") return directCode;
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
const causeCode =
cause && typeof cause === "object"
? (cause as { code?: unknown }).code
: null;
return typeof causeCode === "string" ? causeCode : null;
}
function getErrorMessage(error: unknown) {
const messages: string[] = [];
if (error instanceof Error) messages.push(error.message);
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
if (cause instanceof Error) messages.push(cause.message);
if (!messages.length) messages.push(String(error));
return messages.join("\n");
}
function isMissingAccessIpBlocklistTableError(error: unknown) {
if (getErrorCode(error) === "42P01") return true;
const message = getErrorMessage(error);
return (
message.includes('relation "AccessIpBlocklist" does not exist') ||
(message.includes("AccessIpBlocklist") && message.includes("does not exist"))
);
}
function markBlocklistUnavailable(error: unknown) {
blocklistUnavailableUntil = Date.now() + BLOCKLIST_UNAVAILABLE_CACHE_MS;
if (blocklistMissingTableWarned) return;
blocklistMissingTableWarned = true;
console.warn(
"[AccessIpBlocklist] table is unavailable; manual IP block checks will be skipped temporarily",
getErrorMessage(error)
);
}
export async function POST(request: Request) {
const expectedSecret = getExpectedSecret();
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
if (!expectedSecret || providedSecret !== expectedSecret) {
return new NextResponse("Not found", { status: 404 });
}
const parsed = payloadSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success || !parsed.data.ip) {
return NextResponse.json({ blocked: false });
}
if (
process.env.MANUAL_IP_BLOCKLIST_ENABLED === "false" ||
Date.now() < blocklistUnavailableUntil
) {
return NextResponse.json({ blocked: false });
}
try {
const now = new Date();
const entries = await db
.select({
id: accessIpBlocklist.id,
ip: accessIpBlocklist.ip,
reason: accessIpBlocklist.reason,
expiresAt: accessIpBlocklist.expiresAt,
})
.from(accessIpBlocklist)
.where(
and(
eq(accessIpBlocklist.isActive, true),
or(
isNull(accessIpBlocklist.expiresAt),
gt(accessIpBlocklist.expiresAt, now)
)
)
)
.limit(1000);
const activeEntries = entries.filter((entry) => entry.ip && entry.id);
const matched = matchManualIpBlock(parsed.data.ip, activeEntries);
return NextResponse.json({
blocked: Boolean(matched),
entry: matched
? {
id: matched.id,
ip: matched.ip,
reason: matched.reason || null,
expiresAt: matched.expiresAt || null,
}
: null,
});
} catch (error) {
if (isMissingAccessIpBlocklistTableError(error)) {
markBlocklistUnavailable(error);
return NextResponse.json({ blocked: false });
}
console.warn("[AccessIpBlocklist] internal check failed", error);
return NextResponse.json({ blocked: false });
}
}