import "server-only"; import { sql } from "drizzle-orm"; import { headers } from "next/headers"; import { db } from "@/lib/db"; import { accessIpEvent } from "@/lib/db/schema"; export type AccessLogSeverity = "info" | "watch" | "high" | "critical"; export type RecordAccessIpEventInput = { eventType?: string | null; ip?: string | null; userId?: string | null; userEmail?: string | null; userRole?: string | null; method?: string | null; path?: string | null; routeGroup?: string | null; statusCode?: number | null; outcome?: string | null; severity?: AccessLogSeverity | string | null; reasons?: string[] | null; userAgent?: string | null; referer?: string | null; metadata?: Record | null; occurredAt?: Date | string | number | null; }; const SEVERITIES = new Set(["info", "watch", "high", "critical"]); const ACCESS_EVENT_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000; let accessEventUnavailableUntil = 0; let accessEventMissingTableWarned = false; 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 isMissingAccessIpEventTableError(error: unknown) { if (getErrorCode(error) === "42P01") return true; const message = getErrorMessage(error); return ( message.includes('relation "AccessIpEvent" does not exist') || (message.includes("AccessIpEvent") && message.includes("does not exist")) ); } function markAccessEventsUnavailable(error: unknown) { accessEventUnavailableUntil = Date.now() + ACCESS_EVENT_UNAVAILABLE_CACHE_MS; if (accessEventMissingTableWarned) return; accessEventMissingTableWarned = true; console.warn( "[AccessIpEvent] table is unavailable; access logging will be skipped temporarily", getErrorMessage(error) ); } function shouldSkipAccessEventWrite() { return ( process.env.ACCESS_LOG_ENABLED === "false" || Date.now() < accessEventUnavailableUntil ); } function clampText(value: unknown, maxLength: number) { const normalized = String(value || "").trim(); if (!normalized) return null; return normalized.slice(0, maxLength); } function normalizeIp(value: unknown) { const normalized = clampText(value, 128); if (!normalized) return "unknown"; return normalized.split(",")[0]?.trim().slice(0, 64) || "unknown"; } function normalizeSeverity(value: unknown): AccessLogSeverity { const normalized = String(value || "").trim().toLowerCase(); return SEVERITIES.has(normalized) ? (normalized as AccessLogSeverity) : "info"; } function normalizeReasons(value: unknown) { if (!Array.isArray(value)) return []; return Array.from( new Set( value .map((item) => clampText(item, 80)) .filter((item): item is string => Boolean(item)) ) ).slice(0, 8); } function normalizeOccurredAt(value: unknown) { if (!value) return new Date(); const date = value instanceof Date ? value : typeof value === "number" ? new Date(value) : new Date(String(value)); return Number.isNaN(date.getTime()) ? new Date() : date; } async function maybePruneOldAccessEvents() { if (Math.random() > 0.005) return; await db.execute(sql` DELETE FROM "AccessIpEvent" WHERE "createdAt" < NOW() - INTERVAL '30 days' `); } export async function recordAccessIpEvent(input: RecordAccessIpEventInput) { if (shouldSkipAccessEventWrite()) return; const ip = normalizeIp(input.ip); await maybePruneOldAccessEvents().catch((error) => { if (isMissingAccessIpEventTableError(error)) { markAccessEventsUnavailable(error); return; } console.warn("[AccessIpEvent] prune failed", error); }); try { await db.insert(accessIpEvent).values({ eventType: clampText(input.eventType, 32) || "request", ip, userId: clampText(input.userId, 64), userEmail: clampText(input.userEmail, 128), userRole: clampText(input.userRole, 32), method: clampText(input.method, 12), path: clampText(input.path, 256), routeGroup: clampText(input.routeGroup, 64), statusCode: typeof input.statusCode === "number" && Number.isFinite(input.statusCode) ? Math.round(input.statusCode) : null, outcome: clampText(input.outcome, 32) || "allowed", severity: normalizeSeverity(input.severity), reasons: normalizeReasons(input.reasons), userAgent: clampText(input.userAgent, 512), referer: clampText(input.referer, 512), metadata: input.metadata || null, occurredAt: normalizeOccurredAt(input.occurredAt), }); } catch (error) { if (isMissingAccessIpEventTableError(error)) { markAccessEventsUnavailable(error); return; } throw error; } } export async function recordAccessIpEventFromHeaders( input: Omit ) { const requestHeaders = await headers(); const forwardedFor = requestHeaders.get("x-forwarded-for"); const realIp = requestHeaders.get("x-real-ip"); await recordAccessIpEvent({ ...input, ip: forwardedFor || realIp, userAgent: requestHeaders.get("user-agent"), referer: requestHeaders.get("referer"), }); }