201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
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<string, unknown> | 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<RecordAccessIpEventInput, "ip" | "userAgent" | "referer">
|
|
) {
|
|
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"),
|
|
});
|
|
}
|