import { getClientIp, isPublicIp, NoopIpIntelligenceProvider, ProxyCheckProvider, shouldBlockIpClassification, type IpClassification, type IpIntelligenceProvider, type IpIntelligenceResult, } from "@minecraft-account-manager/network"; import { ipIntelligence } from "@minecraft-account-manager/database"; import { and, eq, gt } from "drizzle-orm"; import { headers } from "next/headers"; import { db } from "@/lib/database"; import { logger } from "@/lib/logger"; const classifications = new Set([ "unknown", "clear", "vpn", "proxy", "hosting", "tor", ]); function configuredProvider(): IpIntelligenceProvider { if (process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() === "proxycheck") { return new ProxyCheckProvider({ apiKey: process.env.PROXYCHECK_API_KEY?.trim() ?? "" }); } return new NoopIpIntelligenceProvider(); } function cacheHours() { const configured = Number(process.env.IP_INTELLIGENCE_CACHE_HOURS ?? "48"); return Number.isFinite(configured) && configured > 0 ? Math.min(configured, 720) : 48; } function serialize(result: IpIntelligenceResult) { return JSON.parse(JSON.stringify(result)) as Record; } function deserialize(value: Record | null): IpIntelligenceResult | null { if (!value || !classifications.has(value.classification as IpClassification)) return null; return value as unknown as IpIntelligenceResult; } async function cacheResult( ipAddress: string, result: IpIntelligenceResult, now: Date, ttlMilliseconds: number, ) { await db .insert(ipIntelligence) .values({ ipAddress, classification: result.classification, provider: result.provider, rawResponse: serialize(result), checkedAt: now, expiresAt: new Date(now.getTime() + ttlMilliseconds), }) .onConflictDoUpdate({ target: ipIntelligence.ipAddress, set: { classification: result.classification, provider: result.provider, rawResponse: serialize(result), checkedAt: now, expiresAt: new Date(now.getTime() + ttlMilliseconds), updatedAt: now, }, }); } export async function getIpIntelligence( ipAddress: string, options: { now?: Date; forceRefresh?: boolean } = {}, ): Promise { if (!isPublicIp(ipAddress)) { logger.warn( { event: "ip_intelligence.skipped", reason: "non_public_address", }, "IP intelligence lookup skipped for a non-public client address", ); return { classification: "unknown", provider: null }; } const now = options.now ?? new Date(); if (!options.forceRefresh) { const [cached] = await db .select({ rawResponse: ipIntelligence.rawResponse }) .from(ipIntelligence) .where(andAddressIsFresh(ipAddress, now)) .limit(1); const cachedResult = deserialize(cached?.rawResponse ?? null); if (cachedResult) return cachedResult; } const provider = configuredProvider(); try { const result = await provider.classify(ipAddress); await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000); return result; } catch (error) { const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null; const result: IpIntelligenceResult = { classification: "unknown", provider: providerName, lookupError: true, }; await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => { logger.error( { err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName }, "Failed to cache an IP intelligence lookup error", ); }); logger.error( { err: error, event: "ip_intelligence.lookup_failed", provider: providerName }, "IP intelligence lookup failed", ); return result; } } function andAddressIsFresh(ipAddress: string, now: Date) { return and(eq(ipIntelligence.ipAddress, ipAddress), gt(ipIntelligence.expiresAt, now)); } export function toAuditIpData(result: IpIntelligenceResult) { return { classification: result.classification, provider: result.provider, riskScore: result.riskScore ?? null, location: result.location ?? null, network: result.network ?? null, lookupError: result.lookupError ?? false, }; } export function shouldBlockAccountAddition(result: IpIntelligenceResult) { return shouldBlockIpClassification( result.classification, process.env.BLOCK_HOSTING_IPS === "true", ); } export async function checkAccountAdditionNetwork() { const requestHeaders = await headers(); const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); if (!ipAddress) { logger.warn( { event: "client_ip.unavailable", trustProxy: process.env.TRUST_PROXY === "true", forwardedForPresent: requestHeaders.has("x-forwarded-for"), realIpPresent: requestHeaders.has("x-real-ip"), }, "Client IP address was unavailable for account addition", ); return { allowed: false as const, reason: "unavailable" as const, ipAddress: null, intelligence: null, }; } const intelligence = await getIpIntelligence(ipAddress); if (shouldBlockAccountAddition(intelligence)) { return { allowed: false as const, reason: intelligence.classification === "unknown" ? "unavailable" as const : "blocked" as const, ipAddress, intelligence, }; } return { allowed: true as const, reason: null, ipAddress, intelligence }; }