ip geolocation + ping analysis

This commit is contained in:
NextJS
2026-06-02 10:24:58 +00:00
parent dc8377177a
commit 4ed4a10eae
21 changed files with 1141 additions and 168 deletions
+37 -8
View File
@@ -6,27 +6,29 @@ import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { parseStoredCoins } from "@/lib/coins-rc";
import { createAdminSupabase } from "@/lib/supabase/admin";
function normalizeValue(raw: FormDataEntryValue | null): string | null {
if (raw == null) return null;
const s = String(raw).trim();
return s === "" ? null : s;
const EMPTY = "__empty__";
const INVALID = "__invalid__";
function normalizeValue(raw: FormDataEntryValue | null): string | typeof EMPTY {
const s = raw == null ? "" : String(raw).trim();
return s === "" ? EMPTY : s;
}
function normalizeSettingValue(
key: string,
raw: FormDataEntryValue | null,
): string | null {
): string | typeof EMPTY | typeof INVALID {
const s = raw == null ? "" : String(raw).trim();
if (key === "entry_fee") {
if (s === "") return null;
if (s === "") return EMPTY;
const coins = parseStoredCoins(s);
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
return String(coins);
}
if (key === "bet_fee") {
if (s === "") return null;
if (s === "") return EMPTY;
const n = Math.round(Number(s));
if (!Number.isFinite(n)) return "__invalid__";
return String(Math.min(100, Math.max(0, n)));
@@ -47,7 +49,7 @@ export async function updateSetting(formData: FormData) {
}
const value = normalizeSettingValue(key, formData.get("value"));
if (value === "__invalid__") {
if (value === INVALID || value === EMPTY) {
redirect("/settings?saveError=1");
}
@@ -80,6 +82,9 @@ export async function insertSetting(formData: FormData) {
if (!key) {
redirect("/settings?addError=missing");
}
if (value === EMPTY) {
redirect("/settings?addError=missingValue");
}
const supabase = createAdminSupabase();
if (!supabase) {
@@ -95,3 +100,27 @@ export async function insertSetting(formData: FormData) {
redirect("/settings");
}
export async function deleteSetting(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const key = String(formData.get("key") ?? "").trim();
if (!key) {
redirect("/settings?saveError=1");
}
const supabase = createAdminSupabase();
if (!supabase) {
redirect("/settings?saveError=1");
}
const { error } = await supabase.from("settings").delete().eq("key", key);
if (error) {
redirect("/settings?saveError=1");
}
redirect("/settings");
}
+28 -2
View File
@@ -1,4 +1,12 @@
import { NextResponse } from "next/server";
import { getClientIp } from "@/lib/auth/client-ip";
import { verifyAdminCredentials } from "@/lib/auth/credentials";
import {
checkLoginRateLimit,
clearLoginAttempts,
rateLimitedLoginResponse,
recordFailedLogin,
} from "@/lib/auth/login-rate-limit";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import {
applySessionCookie,
@@ -10,8 +18,23 @@ function invalidRedirect(request: Request) {
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
}
function invalidJson() {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
export async function POST(request: Request) {
const contentType = request.headers.get("content-type") ?? "";
const clientIp = getClientIp(request);
const rateLimit = checkLoginRateLimit(clientIp);
if (!rateLimit.allowed) {
return rateLimitedLoginResponse(
request,
contentType,
rateLimit.retryAfterSeconds,
publicRequestUrl,
);
}
let username: string;
let password: string;
@@ -31,13 +54,16 @@ export async function POST(request: Request) {
password = String(formData.get("password") ?? "");
}
if (username !== "admin" || password !== "admin") {
if (!verifyAdminCredentials(username, password)) {
recordFailedLogin(clientIp);
if (contentType.includes("application/json")) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
return invalidJson();
}
return invalidRedirect(request);
}
clearLoginAttempts(clientIp);
if (contentType.includes("application/json")) {
const res = NextResponse.json({ ok: true });
res.cookies.set(
+6 -10
View File
@@ -5,6 +5,7 @@ type Props = {
export default async function LoginPage({ searchParams }: Props) {
const { error } = await searchParams;
const invalid = error === "1";
const locked = error === "locked";
return (
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
@@ -12,16 +13,6 @@ export default async function LoginPage({ searchParams }: Props) {
<h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Admin sign in
</h1>
<p className="mt-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
Default:{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
admin
</code>{" "}
/{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
admin
</code>
</p>
<form
action="/api/auth/login"
method="post"
@@ -58,6 +49,11 @@ export default async function LoginPage({ searchParams }: Props) {
required
/>
</div>
{locked ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
Too many failed attempts. Wait about 15 minutes, then try again.
</p>
) : null}
{invalid ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
Invalid username or password.
+33 -7
View File
@@ -25,6 +25,10 @@ import {
} from "@/lib/match-log-analysis-server";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
loadPingAnalytics,
type PingAnalyticsResult,
} from "@/lib/ping-analytics";
import type {
AdminMatchRow,
DbMatch,
@@ -118,6 +122,22 @@ export default async function Home({
let analysisTo = utcLast30DaysDateRange().to;
let analysisPlayerIds: number[] = [];
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
let pingAnalytics: PingAnalyticsResult = {
summary: {
totalReports: 0,
totalMatches: 0,
totalPlayers: 0,
avgPing: null,
p95Ping: null,
badThresholdMs: 120,
badReports: 0,
badRatePercent: null,
},
byMatches: [],
byPlayers: [],
byCountries: [],
error: null,
};
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
null;
@@ -141,7 +161,7 @@ export default async function Home({
} else {
const usersRes = await supabase
.from("users")
.select("id, created_at, username, cc, rc, last_logged_at")
.select("id, created_at, username, email, ip_address, cc, rc, last_logged_at")
.order("id", { ascending: false })
.limit(500);
@@ -252,12 +272,17 @@ export default async function Home({
analysisFrom = range.from;
analysisTo = range.to;
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
matchAnalysis = await analyzeMatchLogsForMatches(
matches,
analysisFrom,
analysisTo,
analysisPlayerIds,
);
const [matchLogResult, pingResult] = await Promise.all([
analyzeMatchLogsForMatches(
matches,
analysisFrom,
analysisTo,
analysisPlayerIds,
),
loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
]);
matchAnalysis = matchLogResult;
pingAnalytics = pingResult;
}
}
@@ -309,6 +334,7 @@ export default async function Home({
analysisTo={analysisTo}
analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis}
pingAnalytics={pingAnalytics}
/>
{editUser ? (
<EditUserCcRcOverlay