ip geolocation + ping analysis
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user