feat(network): add ProxyCheck IP intelligence
This commit is contained in:
@@ -7,6 +7,7 @@ import { redirect } from "next/navigation";
|
||||
import { recordUserEvent } from "@/lib/audit";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
@@ -27,6 +28,21 @@ export async function addMinecraftAccount(formData: FormData) {
|
||||
const confirmed = formData.get("confirmUnverified") === "yes";
|
||||
if (!USERNAME_PATTERN.test(requestedUsername)) redirect("/account?error=invalid-username");
|
||||
|
||||
const network = await checkAccountAdditionNetwork();
|
||||
if (!network.allowed) {
|
||||
await recordUserEvent(
|
||||
user,
|
||||
network.reason === "blocked"
|
||||
? "games.minecraft.account-manager.network.vpn-blocked"
|
||||
: "games.minecraft.account-manager.network.classification-unavailable",
|
||||
{
|
||||
attemptedUsername: requestedUsername,
|
||||
ipIntelligence: network.intelligence ? toAuditIpData(network.intelligence) : null,
|
||||
},
|
||||
);
|
||||
redirect(`/account?error=${network.reason === "blocked" ? "vpn-blocked" : "ip-check-unavailable"}`);
|
||||
}
|
||||
|
||||
const profile = await lookupJavaProfile(requestedUsername);
|
||||
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
|
||||
import { ipIntelligence, ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { logout } from "@/app/auth/actions";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { intelligenceSummary } from "@/lib/event-ip-summary";
|
||||
import {
|
||||
addMinecraftAccount,
|
||||
confirmDashboardNickname,
|
||||
@@ -19,6 +20,8 @@ const errorMessages: Record<string, string> = {
|
||||
"unknown-account": "That account is no longer available.",
|
||||
"nickname-not-configured": "Discord nickname updates are not configured.",
|
||||
"nickname-update-failed": "Discord rejected the nickname update. An admin may need to adjust bot permissions.",
|
||||
"vpn-blocked": "Turn off your VPN, proxy, or Tor connection before adding a Minecraft account.",
|
||||
"ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.",
|
||||
};
|
||||
|
||||
export default async function AccountPage({
|
||||
@@ -35,8 +38,16 @@ export default async function AccountPage({
|
||||
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: ipObservations.id,
|
||||
ipAddress: ipObservations.ipAddress,
|
||||
source: ipObservations.source,
|
||||
classification: ipObservations.classification,
|
||||
observedAt: ipObservations.observedAt,
|
||||
intelligence: ipIntelligence.rawResponse,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(eq(ipObservations.userId, user.id))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(20),
|
||||
@@ -126,7 +137,10 @@ export default async function AccountPage({
|
||||
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
|
||||
{observations.length ? (
|
||||
<div className="divide-y divide-line font-mono text-xs">
|
||||
{observations.map((observation) => <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><span>{observation.ipAddress}</span><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>)}
|
||||
{observations.map((observation) => {
|
||||
const summary = intelligenceSummary(observation.intelligence);
|
||||
return <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><div><p>{observation.ipAddress}</p><p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}</p></div><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>;
|
||||
})}
|
||||
</div>
|
||||
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { events } from "@minecraft-account-manager/database";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { db } from "@/lib/database";
|
||||
import { eventIpSummary } from "@/lib/event-ip-summary";
|
||||
|
||||
export default async function EventsPage() {
|
||||
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
|
||||
@@ -12,18 +13,22 @@ export default async function EventsPage() {
|
||||
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th></tr>
|
||||
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th><th className="p-4">Network</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line text-xs">
|
||||
{recentEvents.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
|
||||
<td className="p-4 font-mono font-bold">{event.type}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={4}>No events have been recorded.</td></tr>}
|
||||
{recentEvents.map((event) => {
|
||||
const ip = eventIpSummary(event.data);
|
||||
return (
|
||||
<tr key={event.id}>
|
||||
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
|
||||
<td className="p-4 font-mono font-bold">{event.type}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
|
||||
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events have been recorded.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, desc, eq, isNull, or } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/lib/database";
|
||||
import { eventIpSummary } from "@/lib/event-ip-summary";
|
||||
import {
|
||||
addUserMinecraftAccount,
|
||||
removeUserMinecraftAccount,
|
||||
@@ -140,7 +141,10 @@ export default async function AdminUserPage({
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p>
|
||||
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Recent events</h2>
|
||||
<div className="divide-y divide-line">
|
||||
{recentEvents.map((event) => <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><span className="font-mono text-xs font-bold">{event.type}</span><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>)}
|
||||
{recentEvents.map((event) => {
|
||||
const ip = eventIpSummary(event.data);
|
||||
return <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><p className="font-mono text-xs font-bold">{event.type}</p>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>;
|
||||
})}
|
||||
{!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
||||
@@ -45,6 +46,35 @@ export async function POST(request: Request) {
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
|
||||
|
||||
let [knownAccount] = await db
|
||||
.select({ id: minecraftAccounts.id })
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!knownAccount) {
|
||||
[knownAccount] = await db
|
||||
.select({ id: minecraftAccounts.id })
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
isNull(minecraftAccounts.minecraftUuid),
|
||||
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
const intelligence = knownAccount
|
||||
? await getIpIntelligence(input.ipAddress)
|
||||
: { classification: "unknown" as const, provider: null };
|
||||
const auditIpData = toAuditIpData(intelligence);
|
||||
|
||||
try {
|
||||
const decision = await db.transaction(async (tx) => {
|
||||
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
||||
@@ -97,7 +127,11 @@ export async function POST(request: Request) {
|
||||
type: "games.minecraft.account-manager.game.login.denied",
|
||||
subject: `minecraft-account/${input.minecraftUuid}`,
|
||||
time: occurredAt,
|
||||
data: { username: input.username, reason: "not_registered" },
|
||||
data: {
|
||||
username: input.username,
|
||||
reason: "not_registered",
|
||||
ipIntelligence: auditIpData,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
@@ -106,7 +140,7 @@ export async function POST(request: Request) {
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: "unknown",
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: denialMessage };
|
||||
@@ -145,7 +179,7 @@ export async function POST(request: Request) {
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: "unknown",
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
await tx.insert(events).values({
|
||||
@@ -160,6 +194,7 @@ export async function POST(request: Request) {
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
previousUsername: account.username === input.username ? null : account.username,
|
||||
uuidBackfilled: account.minecraftUuid === null,
|
||||
ipIntelligence: auditIpData,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const code = request.nextUrl.searchParams.get("code") ?? "";
|
||||
@@ -13,6 +14,7 @@ export async function GET(request: NextRequest) {
|
||||
repository: createAuthRepository(db),
|
||||
});
|
||||
const ipAddress = getClientIp(request.headers, process.env.TRUST_PROXY === "true");
|
||||
const intelligence = ipAddress ? await getIpIntelligence(ipAddress) : null;
|
||||
await Promise.all([
|
||||
recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.auth.magic-link.consumed",
|
||||
@@ -20,14 +22,17 @@ export async function GET(request: NextRequest) {
|
||||
subject: `user/${result.user.id}`,
|
||||
actorUserId: result.user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { isNewUser: result.isNewUser },
|
||||
data: {
|
||||
isNewUser: result.isNewUser,
|
||||
ipIntelligence: intelligence ? toAuditIpData(intelligence) : null,
|
||||
},
|
||||
}),
|
||||
ipAddress
|
||||
? db.insert(ipObservations).values({
|
||||
userId: result.user.id,
|
||||
source: "web",
|
||||
ipAddress,
|
||||
classification: "unknown",
|
||||
classification: intelligence?.classification ?? "unknown",
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { redirect } from "next/navigation";
|
||||
import { recordUserEvent } from "@/lib/audit";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
@@ -32,6 +33,21 @@ export async function addFirstMinecraftAccount(formData: FormData) {
|
||||
redirect("/welcome/minecraft?error=invalid-format");
|
||||
}
|
||||
|
||||
const network = await checkAccountAdditionNetwork();
|
||||
if (!network.allowed) {
|
||||
await recordUserEvent(
|
||||
user,
|
||||
network.reason === "blocked"
|
||||
? "games.minecraft.account-manager.network.vpn-blocked"
|
||||
: "games.minecraft.account-manager.network.classification-unavailable",
|
||||
{
|
||||
attemptedUsername: requestedUsername,
|
||||
ipIntelligence: network.intelligence ? toAuditIpData(network.intelligence) : null,
|
||||
},
|
||||
);
|
||||
redirect(`/welcome/minecraft?error=${network.reason === "blocked" ? "vpn-blocked" : "ip-check-unavailable"}`);
|
||||
}
|
||||
|
||||
const profile = await lookupJavaProfile(requestedUsername);
|
||||
if (!profile && !confirmed) {
|
||||
redirect(`/welcome/minecraft?unverified=${encodeURIComponent(requestedUsername)}`);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { addFirstMinecraftAccount } from "../actions";
|
||||
const errors: Record<string, string> = {
|
||||
"invalid-format": "Java usernames use 3–16 letters, numbers, or underscores.",
|
||||
"already-registered": "That Minecraft account is already registered.",
|
||||
"vpn-blocked": "Turn off your VPN, proxy, or Tor connection before adding a Minecraft account.",
|
||||
"ip-check-unavailable": "We could not verify your network. Try again without a VPN or contact an administrator.",
|
||||
};
|
||||
|
||||
export default async function MinecraftStepPage({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
type UnknownMap = Record<string, unknown>;
|
||||
|
||||
function objectValue(value: unknown): UnknownMap | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as UnknownMap
|
||||
: null;
|
||||
}
|
||||
|
||||
export function intelligenceSummary(value: unknown) {
|
||||
const intelligence = objectValue(value);
|
||||
const location = objectValue(intelligence?.location);
|
||||
const locationLabel = [location?.city, location?.region, location?.countryCode ?? location?.country]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.join(", ");
|
||||
const classification = typeof intelligence?.classification === "string"
|
||||
? intelligence.classification
|
||||
: null;
|
||||
|
||||
return {
|
||||
location: locationLabel || null,
|
||||
classification,
|
||||
};
|
||||
}
|
||||
|
||||
export function eventIpSummary(data: UnknownMap) {
|
||||
return intelligenceSummary(data.ipIntelligence);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user