Files
minecraft-account-manager/apps/web/src/lib/ip-intelligence.ts
T

159 lines
4.6 KiB
TypeScript

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";
const classifications = new Set<IpClassification>([
"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<string, unknown>;
}
function deserialize(value: Record<string, unknown> | 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<IpIntelligenceResult> {
if (!isPublicIp(ipAddress)) {
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 {
const result: IpIntelligenceResult = {
classification: "unknown",
provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null,
lookupError: true,
};
await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined);
console.error("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) {
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 };
}