diff --git a/.env.example b/.env.example index a439675..930c1a8 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ +# Admin panel login (server only; required in production) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-to-a-strong-password + # Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects). # Example: https://kickkings.playpoolstudios.com # APP_ORIGIN= @@ -16,3 +20,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Directory containing matchmaker logs: history.log (processed) and matchmaker.log (raw). # Example: /var/www/html/kickkings/logs/matchmaker/logs # MATCHMAKER_LOGS_DIR= + +# Third-party IP geolocation provider (ipgeolocation.io) +# Used for country code lookup from user IP and ping report IP. +IPGEOLOCATION_API_KEY=your-ipgeolocation-api-key diff --git a/deploy/kickkings-admin.service b/deploy/kickkings-admin.service new file mode 100644 index 0000000..7465c43 --- /dev/null +++ b/deploy/kickkings-admin.service @@ -0,0 +1,25 @@ +[Unit] +Description=KickKings Admin (Next.js) +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=next +Group=next +WorkingDirectory=/home/next/kickkings_admin + +Environment=NODE_ENV=production +Environment=PATH=/home/next/.nvm/versions/node/v24.13.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +ExecStart=/home/next/.nvm/versions/node/v24.13.0/bin/npm run start + +Restart=on-failure +RestartSec=5 + +# Hardening (optional; remove if the service fails to start) +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/next.config.ts b/next.config.ts index e9ffa30..cb651cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,5 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = { - /* config options here */ -}; +const nextConfig: NextConfig = {}; export default nextConfig; diff --git a/schemas/ping_reports.md b/schemas/ping_reports.md new file mode 100644 index 0000000..7167aee --- /dev/null +++ b/schemas/ping_reports.md @@ -0,0 +1,11 @@ +create table public.ping_reports ( + id bigint generated by default as identity not null, + created_at timestamp with time zone not null default now(), + user_id bigint not null, + match_id bigint not null, + ip_address text null, + ping integer not null, + constraint ping_reports_pkey primary key (id), + constraint ping_reports_user_id_fkey foreign key (user_id) references users (id), + constraint ping_reports_match_id_fkey foreign key (match_id) references matches (id) +) TABLESPACE pg_default; diff --git a/schemas/users.md b/schemas/users.md index 7bc6705..addce4f 100644 --- a/schemas/users.md +++ b/schemas/users.md @@ -2,7 +2,9 @@ create table public.users ( id bigint generated by default as identity not null, created_at timestamp with time zone not null default now(), username text null, + email text null, password text null, + ip_address text null, cc real null default '0'::real, rc real null default '0'::real, last_logged_at timestamp with time zone null default now(), diff --git a/src/app/actions/settings-actions.ts b/src/app/actions/settings-actions.ts index 7b2014e..0b6e9a3 100644 --- a/src/app/actions/settings-actions.ts +++ b/src/app/actions/settings-actions.ts @@ -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"); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f6d5f36..d01072d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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( diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index cfd7cad..3675921 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 (
@@ -12,16 +13,6 @@ export default async function LoginPage({ searchParams }: Props) {

Admin sign in

-

- Default:{" "} - - admin - {" "} - /{" "} - - admin - -

+ {locked ? ( +

+ Too many failed attempts. Wait about 15 minutes, then try again. +

+ ) : null} {invalid ? (

Invalid username or password. diff --git a/src/app/page.tsx b/src/app/page.tsx index 8b73b9a..bd5811e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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> | 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 ? ( ("wins"); const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc"); @@ -330,6 +334,25 @@ export function AdminDashboard({ if (!hideNoWinner) return filteredMatches; return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id)); }, [filteredMatches, hideNoWinner]); + const filteredUsers = useMemo(() => { + const q = playersSearch.trim().toLowerCase(); + if (!q) return users; + return users.filter((u) => { + const haystack = [ + String(u.id), + u.username ?? "", + u.email ?? "", + u.cc == null ? "" : String(u.cc), + u.rc == null ? "" : String(u.rc), + formatTs(u.created_at), + formatTs(u.last_logged_at), + u.ip_address ?? "", + ] + .join(" ") + .toLowerCase(); + return haystack.includes(q); + }); + }, [users, playersSearch]); useEffect(() => { if (tab !== "players" || !highlightId) return; @@ -546,6 +569,7 @@ export function AdminDashboard({ activeDir={lbSortDir} onActivate={onLbSort} /> + Email No wins recorded yet (no rows with a winner). @@ -603,6 +627,9 @@ export function AdminDashboard({ )} + + {row.email?.trim() ? row.email.trim() : "—"} + {formatRcBalanceWithCoins(row.rcBalance)} @@ -630,31 +657,51 @@ export function AdminDashboard({ {usersError ? (

{usersError}

) : ( +
+
+ + + Showing {filteredUsers.length} of {users.length} + +
+ + - {users.length === 0 ? ( + {filteredUsers.length === 0 ? ( ) : ( - users.map((u) => { + filteredUsers.map((u) => { const isHi = highlightId != null && highlightId === String(u.id); @@ -672,6 +719,9 @@ export function AdminDashboard({ + +
ID UsernameEmail CC RC Created Last seenIP Actions
- No players yet. + {users.length === 0 + ? "No players yet." + : "No players match this search."}
{u.username ?? "—"} + {u.email?.trim() ? u.email.trim() : "—"} + {u.cc ?? "—"} {u.rc ?? "—"} @@ -680,6 +730,9 @@ export function AdminDashboard({ {formatTs(u.last_logged_at)} + {u.ip_address?.trim() ? u.ip_address.trim() : "—"} +
+
)} ) : tab === "matchmaker" ? ( @@ -751,6 +805,7 @@ export function AdminDashboard({ ) : tab === "analysis" ? ( +

+ {label} +

+

+ {value} +

+ + ); +} + type Props = { analysis: MatchLogAnalysisResult; + pingAnalytics: PingAnalyticsResult; users: DbUser[]; analysisFrom: string; analysisTo: string; @@ -90,6 +105,7 @@ type Props = { export function AdminMatchAnalysis({ analysis, + pingAnalytics, users, analysisFrom, analysisTo, @@ -116,6 +132,34 @@ export function AdminMatchAnalysis({ ? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100 : null; + const fmtMs = (value: number | null): string => + value == null ? "—" : `${Math.round(value).toLocaleString("en-US")} ms`; + const fmtPct = (value: number | null): string => + value == null + ? "—" + : `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })}%`; + + const reports = pingAnalytics.summary.totalReports; + const avgPing = pingAnalytics.summary.avgPing; + const p95Ping = pingAnalytics.summary.p95Ping; + const badThreshold = pingAnalytics.summary.badThresholdMs; + const badReports = pingAnalytics.summary.badReports; + const badRate = pingAnalytics.summary.badRatePercent; + const goodCount = + reports > 0 && avgPing != null + ? Math.max(0, reports - badReports) + : Math.max(0, reports - badReports); + const topCountryRows = pingAnalytics.byCountries.slice(0, 8); + const [pingView, setPingView] = useState<"matches" | "players" | "countries">( + "matches", + ); + const pingRows = + pingView === "matches" + ? pingAnalytics.byMatches + : pingView === "players" + ? pingAnalytics.byPlayers + : pingAnalytics.byCountries; + return (
{analysis.configError ? ( @@ -394,6 +438,183 @@ export function AdminMatchAnalysis({ )}
) : null} + +
+
+

+ Ping latency analytics +

+

+ Visual summary of current ping health and worst latency countries in this filter. +

+
+ + {pingAnalytics.error ? ( +
+ Failed to load ping reports: {pingAnalytics.error} +
+ ) : ( + <> +
+ + + + = ${badThreshold}ms`} + value={`${badReports.toLocaleString("en-US")} (${fmtPct(badRate)})`} + /> +
+ +
+

+ Ping quality split +

+
+ + +
+
+ +
+
+

+ Ping breakdown +

+
+ + + +
+
+ + {pingRows.length === 0 ? ( +

+ No ping data for this filter. +

+ ) : ( +
+ {pingRows.map((row) => ( + + ))} +
+ )} +
+ + )} +
); } + +function SplitBar({ + label, + value, + total, + colorClassName, +}: { + label: string; + value: number; + total: number; + colorClassName: string; +}) { + const pct = total > 0 ? Math.max(0, Math.min(100, (value / total) * 100)) : 0; + return ( +
+
+ {label} + + {value.toLocaleString("en-US")} ({pct.toLocaleString("en-US", { maximumFractionDigits: 1 })}%) + +
+
+
+
+
+ ); +} + +function PingEntryBar({ + label, + minPing, + avgPing, + maxPing, +}: { + label: string; + minPing: number; + avgPing: number; + maxPing: number; +}) { + const clamped = Math.max(0, Math.min(400, avgPing)); + const widthPct = (clamped / 400) * 100; + return ( +
+
+ {label} + + min {Math.round(minPing).toLocaleString("en-US")} ms · avg {Math.round(avgPing).toLocaleString("en-US")} ms · max {Math.round(maxPing).toLocaleString("en-US")} ms + +
+
+
+
+
+ ); +} diff --git a/src/components/admin-settings-editor.tsx b/src/components/admin-settings-editor.tsx index 43cdba0..57250e5 100644 --- a/src/components/admin-settings-editor.tsx +++ b/src/components/admin-settings-editor.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { insertSetting, + deleteSetting, updateSetting, } from "@/app/actions/settings-actions"; import { @@ -20,39 +21,58 @@ type Props = { }; function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) { + const [value, setValue] = useState(row.value ?? ""); + const saveDisabled = value.trim() === ""; + return ( - - -
-
-
- + +
); } @@ -64,50 +84,64 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) { const [v, setV] = useState(initial); return ( -
- - -
-
-
-
); } @@ -123,79 +157,103 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) { return rcToCoins(Number(t)); }, [rcText]); + const saveDisabled = rcText.trim() === ""; + return ( -
{ - setLocalError(null); - const trimmed = rcText.trim(); - if (trimmed === "") { - e.preventDefault(); - setLocalError("Enter an RC value (one decimal place; tenths digit 0–3)."); - return; - } - const rc = Number(trimmed); - const coins = rcToCoins(rc); - if (coins === null) { - e.preventDefault(); - setLocalError( - "Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).", - ); - } - }} - > - - -
-
-
- + +
); } @@ -214,6 +272,9 @@ export function AdminSettingsEditor({ saveError, addError, }: Props) { + const [newKey, setNewKey] = useState(""); + const [newValue, setNewValue] = useState(""); + return (
{saveError ? ( @@ -255,6 +316,10 @@ export function AdminSettingsEditor({

Enter a non-empty key.

+ ) : addError === "missingValue" ? ( +

+ Enter a non-empty value. +

) : addError === "config" ? (

Supabase admin client is not configured. @@ -277,7 +342,8 @@ export function AdminSettingsEditor({ id="new-setting-key" name="newKey" type="text" - required + value={newKey} + onChange={(e) => setNewKey(e.target.value)} autoComplete="off" placeholder="e.g. maintenance_message" className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100" @@ -294,6 +360,8 @@ export function AdminSettingsEditor({ id="new-setting-value" name="newValue" type="text" + value={newValue} + onChange={(e) => setNewValue(e.target.value)} autoComplete="off" className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100" /> @@ -301,6 +369,7 @@ export function AdminSettingsEditor({

{load.status === "ok" ? (
+
+ Email + + {load.data.email?.trim() ? load.data.email.trim() : "—"} + +
Account created @@ -165,6 +172,25 @@ export function PlayerCardModal() { {formatTsUtc(load.data.lastSeen)}
+
+ Last logged in IP + + {load.data.lastLoggedInIp?.trim() + ? load.data.lastLoggedInIp.trim() + : "—"} + +
+
+ Country code + + {(() => { + const code = load.data.lastLoggedInCountryCode?.trim(); + if (!code) return "—"; + const flag = countryFlagFromCode(code); + return flag ? `${flag} ${code}` : code; + })()} + +
) : null}
diff --git a/src/lib/auth/client-ip.ts b/src/lib/auth/client-ip.ts new file mode 100644 index 0000000..a91cd7b --- /dev/null +++ b/src/lib/auth/client-ip.ts @@ -0,0 +1,16 @@ +/** + * Best-effort client IP for rate limiting behind a reverse proxy. + * Falls back to a sentinel when unknown so attempts are still counted. + */ +export function getClientIp(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + + return "unknown"; +} diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts new file mode 100644 index 0000000..bd8d4ea --- /dev/null +++ b/src/lib/auth/credentials.ts @@ -0,0 +1,30 @@ +import { timingSafeEqual } from "node:crypto"; + +function safeEqual(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) return false; + return timingSafeEqual(aBuf, bBuf); +} + +export function getAdminUsername(): string { + return process.env.ADMIN_USERNAME?.trim() || "admin"; +} + +export function getAdminPassword(): string | null { + const password = process.env.ADMIN_PASSWORD?.trim(); + return password || null; +} + +export function verifyAdminCredentials( + username: string, + password: string, +): boolean { + const expectedUsername = getAdminUsername(); + const expectedPassword = getAdminPassword(); + if (!expectedPassword) return false; + return ( + safeEqual(username, expectedUsername) && + safeEqual(password, expectedPassword) + ); +} diff --git a/src/lib/auth/login-rate-limit.ts b/src/lib/auth/login-rate-limit.ts new file mode 100644 index 0000000..08e8ee5 --- /dev/null +++ b/src/lib/auth/login-rate-limit.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; + +const MAX_FAILURES = 5; +const WINDOW_MS = 15 * 60 * 1000; +const LOCKOUT_MS = 15 * 60 * 1000; + +type Entry = { + failures: number; + windowStartedAt: number; + lockedUntil: number | null; +}; + +const attemptsByIp = new Map(); + +function getEntry(ip: string): Entry { + const existing = attemptsByIp.get(ip); + if (existing) return existing; + + const entry: Entry = { + failures: 0, + windowStartedAt: Date.now(), + lockedUntil: null, + }; + attemptsByIp.set(ip, entry); + return entry; +} + +function resetWindowIfExpired(entry: Entry, now: number): void { + if (now - entry.windowStartedAt >= WINDOW_MS) { + entry.failures = 0; + entry.windowStartedAt = now; + if (entry.lockedUntil !== null && entry.lockedUntil <= now) { + entry.lockedUntil = null; + } + } +} + +export type LoginRateLimitResult = + | { allowed: true } + | { allowed: false; retryAfterSeconds: number }; + +export function checkLoginRateLimit(ip: string): LoginRateLimitResult { + const now = Date.now(); + const entry = getEntry(ip); + resetWindowIfExpired(entry, now); + + if (entry.lockedUntil !== null && entry.lockedUntil > now) { + return { + allowed: false, + retryAfterSeconds: Math.ceil((entry.lockedUntil - now) / 1000), + }; + } + + if (entry.lockedUntil !== null && entry.lockedUntil <= now) { + entry.lockedUntil = null; + entry.failures = 0; + entry.windowStartedAt = now; + } + + return { allowed: true }; +} + +export function recordFailedLogin(ip: string): void { + const now = Date.now(); + const entry = getEntry(ip); + resetWindowIfExpired(entry, now); + + entry.failures += 1; + if (entry.failures >= MAX_FAILURES) { + entry.lockedUntil = now + LOCKOUT_MS; + } +} + +export function clearLoginAttempts(ip: string): void { + attemptsByIp.delete(ip); +} + +export function rateLimitedLoginResponse( + request: Request, + contentType: string, + retryAfterSeconds: number, + publicRequestUrl: (request: Request, path: string) => URL, +): NextResponse { + const headers = { "Retry-After": String(retryAfterSeconds) }; + + if (contentType.includes("application/json")) { + return NextResponse.json( + { + error: "Too many failed login attempts. Try again later.", + retryAfterSeconds, + }, + { status: 429, headers }, + ); + } + + return NextResponse.redirect( + publicRequestUrl(request, "/login?error=locked"), + { headers }, + ); +} diff --git a/src/lib/dashboard-stats.ts b/src/lib/dashboard-stats.ts index fe292ca..dc54c20 100644 --- a/src/lib/dashboard-stats.ts +++ b/src/lib/dashboard-stats.ts @@ -14,6 +14,7 @@ export type DashboardStatsSnapshot = { export type LeaderboardRow = { userId: number; username: string | null; + email: string | null; /** Matches with `winner_id` equal to this user (same as player card). */ matchesWon: number; rcBalance: number | null; @@ -105,7 +106,7 @@ async function fetchTopPlayersByMatchWins( ); const [usersRes, ...playedRes] = await Promise.all([ - supabase.from("users").select("id, username, rc").in("id", ids), + supabase.from("users").select("id, username, email, rc").in("id", ids), ...playedPromises, ]); @@ -114,10 +115,12 @@ async function fetchTopPlayersByMatchWins( } const nameById = new Map(); + const emailById = new Map(); const rcById = new Map(); for (const u of usersRes.data ?? []) { const uid = u.id as number; nameById.set(uid, (u.username as string | null) ?? null); + emailById.set(uid, (u.email as string | null) ?? null); const rawRc = u.rc as number | null; rcById.set( uid, @@ -146,6 +149,7 @@ async function fetchTopPlayersByMatchWins( return { userId, username: nameById.get(userId) ?? null, + email: emailById.get(userId) ?? null, matchesWon, rcBalance: rcById.get(userId) ?? null, winRatePercent, diff --git a/src/lib/ip-geolocation.ts b/src/lib/ip-geolocation.ts new file mode 100644 index 0000000..2545294 --- /dev/null +++ b/src/lib/ip-geolocation.ts @@ -0,0 +1,87 @@ +const IPV4_WITH_OPTIONAL_PORT = /^(\d{1,3}(?:\.\d{1,3}){3})(?::\d{1,5})?$/; +const COUNTRY_LOOKUP_ENDPOINT = "https://api.ipgeolocation.io/v3/ipgeo"; +const geoLookupCache = new Map>(); + +function normalizeIp(raw: string | null | undefined): string | null { + const value = (raw ?? "").trim(); + if (!value) return null; + if (value.toLowerCase() === "unknown") return null; + + const first = value.split(",")[0]?.trim() ?? value; + if (!first) return null; + + if (first.startsWith("::ffff:")) { + return first.slice("::ffff:".length); + } + + const v4WithPort = IPV4_WITH_OPTIONAL_PORT.exec(first); + if (v4WithPort) { + return v4WithPort[1] ?? null; + } + + // Handle [ipv6]:port style values. + if (first.startsWith("[") && first.includes("]")) { + const end = first.indexOf("]"); + if (end > 1) return first.slice(1, end); + } + + return first; +} + +async function fetchCountryCodeFromApi(ip: string): Promise { + const apiKey = process.env.IPGEOLOCATION_API_KEY?.trim(); + if (!apiKey) return null; + const url = new URL(COUNTRY_LOOKUP_ENDPOINT); + url.searchParams.set("apiKey", apiKey); + url.searchParams.set("ip", ip); + + try { + const res = await fetch(url.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + // 12h cache for same IP in Next server runtime. + next: { revalidate: 60 * 60 * 12 }, + }); + if (!res.ok) return null; + const json = (await res.json()) as { + location?: { country_code2?: string | null }; + }; + const cc = json.location?.country_code2?.trim().toUpperCase(); + return cc ? cc : null; + } catch { + return null; + } +} + +/** Best-effort ISO country code from an IP address (e.g. "IN", "US"). */ +export async function countryCodeFromIp( + ip: string | null | undefined, +): Promise { + const normalized = normalizeIp(ip); + if (!normalized) return null; + + const cached = geoLookupCache.get(normalized); + if (cached) return cached; + + const promise = fetchCountryCodeFromApi(normalized); + geoLookupCache.set(normalized, promise); + const result = await promise; + if (result == null) { + // allow retries for transient API/network failures + geoLookupCache.delete(normalized); + } + return result; +} + +/** Convert ISO country code (e.g. "IN") to emoji flag (e.g. 🇮🇳). */ +export function countryFlagFromCode( + countryCode: string | null | undefined, +): string | null { + const cc = (countryCode ?? "").trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(cc)) return null; + const base = 127397; // Regional indicator symbol letter A offset. + const chars = [...cc].map((ch) => + String.fromCodePoint(base + ch.charCodeAt(0)), + ); + return chars.join(""); +} diff --git a/src/lib/ping-analytics.ts b/src/lib/ping-analytics.ts new file mode 100644 index 0000000..ac65bba --- /dev/null +++ b/src/lib/ping-analytics.ts @@ -0,0 +1,223 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation"; +import type { DbPingReport } from "@/types/database"; + +const BAD_PING_THRESHOLD_MS = 120; +const TOP_ROWS_LIMIT = 20; + +type PingReportWithUser = DbPingReport & { + user: + | { id: number; username: string | null; ip_address: string | null }[] + | null; +}; + +export type PingGroupRow = { + key: string; + label: string; + reports: number; + players: number; + avgPing: number; + minPing: number; + maxPing: number; + badReports: number; + badRatePercent: number; +}; + +export type PingAnalyticsSummary = { + totalReports: number; + totalMatches: number; + totalPlayers: number; + avgPing: number | null; + p95Ping: number | null; + badThresholdMs: number; + badReports: number; + badRatePercent: number | null; +}; + +export type PingAnalyticsResult = { + summary: PingAnalyticsSummary; + byMatches: PingGroupRow[]; + byPlayers: PingGroupRow[]; + byCountries: PingGroupRow[]; + error: string | null; +}; + +function percentile(sorted: number[], ratio: number): number | null { + if (sorted.length === 0) return null; + const idx = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(sorted.length * ratio) - 1), + ); + return sorted[idx] ?? null; +} + +function toGroupRow( + key: string, + label: string, + pings: number[], + players: Set, +): PingGroupRow { + const reports = pings.length; + const sum = pings.reduce((acc, value) => acc + value, 0); + const minPing = reports > 0 ? Math.min(...pings) : 0; + const maxPing = reports > 0 ? Math.max(...pings) : 0; + const badReports = pings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length; + const badRatePercent = reports > 0 ? (badReports / reports) * 100 : 0; + + return { + key, + label, + reports, + players: players.size, + avgPing: reports > 0 ? sum / reports : 0, + minPing, + maxPing, + badReports, + badRatePercent, + }; +} + +export async function loadPingAnalytics( + supabase: SupabaseClient, + from: string, + to: string, + playerIds: number[], +): Promise { + const rangeStartIso = `${from}T00:00:00.000Z`; + const rangeEndIso = `${to}T23:59:59.999Z`; + + let query = supabase + .from("ping_reports") + .select("id, created_at, user_id, match_id, ip_address, ping, user:users(id, username, ip_address)") + .gte("created_at", rangeStartIso) + .lte("created_at", rangeEndIso) + .order("created_at", { ascending: false }) + .limit(10000); + + if (playerIds.length > 0) { + query = query.in("user_id", playerIds); + } + + const { data, error } = await query; + if (error) { + return { + summary: { + totalReports: 0, + totalMatches: 0, + totalPlayers: 0, + avgPing: null, + p95Ping: null, + badThresholdMs: BAD_PING_THRESHOLD_MS, + badReports: 0, + badRatePercent: null, + }, + byMatches: [], + byPlayers: [], + byCountries: [], + error: error.message, + }; + } + + const rows = (data ?? []) as PingReportWithUser[]; + const allPings: number[] = []; + const uniquePlayers = new Set(); + const uniqueMatches = new Set(); + + const byMatch = new Map }>(); + const byPlayer = new Map }>(); + const byCountry = new Map }>(); + const ipByRowIndex = new Map(); + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + const user = row.user?.[0] ?? null; + ipByRowIndex.set(i, row.ip_address ?? user?.ip_address ?? null); + } + const countryByRowIndex = new Map(); + await Promise.all( + [...ipByRowIndex.entries()].map(async ([index, ip]) => { + countryByRowIndex.set(index, await countryCodeFromIp(ip)); + }), + ); + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + const ping = Number(row.ping); + const userId = Number(row.user_id); + const matchId = Number(row.match_id); + if (!Number.isFinite(ping) || !Number.isFinite(userId) || !Number.isFinite(matchId)) { + continue; + } + + allPings.push(ping); + uniquePlayers.add(userId); + uniqueMatches.add(matchId); + + const matchKey = String(matchId); + const matchState = byMatch.get(matchKey) ?? { + label: `M${matchId}`, + pings: [], + players: new Set(), + }; + matchState.pings.push(ping); + matchState.players.add(userId); + byMatch.set(matchKey, matchState); + + const user = row.user?.[0] ?? null; + const username = user?.username?.trim(); + const playerLabel = username ? `${username} (${userId})` : `User ${userId}`; + const playerKey = String(userId); + const playerState = byPlayer.get(playerKey) ?? { + label: playerLabel, + pings: [], + players: new Set(), + }; + playerState.pings.push(ping); + playerState.players.add(userId); + byPlayer.set(playerKey, playerState); + + const country = countryByRowIndex.get(i) ?? "UNK"; + const countryFlag = countryFlagFromCode(country); + const countryWithFlag = countryFlag ? `${countryFlag} ${country}` : country; + const countryState = byCountry.get(country) ?? { + label: countryWithFlag, + pings: [], + players: new Set(), + }; + countryState.pings.push(ping); + countryState.players.add(userId); + byCountry.set(country, countryState); + } + + const sortedPings = [...allPings].sort((a, b) => a - b); + const badReports = allPings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length; + + const toRankedRows = ( + src: Map }>, + ): PingGroupRow[] => + [...src.entries()] + .map(([key, state]) => toGroupRow(key, state.label, state.pings, state.players)) + .sort((a, b) => b.avgPing - a.avgPing || b.reports - a.reports) + .slice(0, TOP_ROWS_LIMIT); + + return { + summary: { + totalReports: allPings.length, + totalMatches: uniqueMatches.size, + totalPlayers: uniquePlayers.size, + avgPing: + allPings.length > 0 + ? allPings.reduce((acc, value) => acc + value, 0) / allPings.length + : null, + p95Ping: percentile(sortedPings, 0.95), + badThresholdMs: BAD_PING_THRESHOLD_MS, + badReports, + badRatePercent: + allPings.length > 0 ? (badReports / allPings.length) * 100 : null, + }, + byMatches: toRankedRows(byMatch), + byPlayers: toRankedRows(byPlayer), + byCountries: toRankedRows(byCountry), + error: null, + }; +} diff --git a/src/lib/player-card-server.ts b/src/lib/player-card-server.ts index d57c629..43fb7c4 100644 --- a/src/lib/player-card-server.ts +++ b/src/lib/player-card-server.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { DbMatch, DbTransaction } from "@/types/database"; import { txAmountToBigInt } from "@/lib/ledger-integrity"; +import { countryCodeFromIp } from "@/lib/ip-geolocation"; const TX_PAGE = 5000; /** Recent matches listed on the player card (newest first). */ @@ -22,8 +23,11 @@ export type PlayerCardMatchHistoryRow = { export type PlayerCardData = { id: number; username: string | null; + email: string | null; createdAt: string | null; lastSeen: string | null; + lastLoggedInIp: string | null; + lastLoggedInCountryCode: string | null; rcBalance: number | null; ccBalance: number | null; matchesPlayed: number; @@ -83,7 +87,7 @@ export async function fetchPlayerCardData( const { data: userRow, error: userErr } = await supabase .from("users") - .select("id, username, created_at, last_logged_at, rc, cc") + .select("id, username, email, created_at, last_logged_at, ip_address, rc, cc") .eq("id", userId) .maybeSingle(); @@ -270,8 +274,13 @@ export async function fetchPlayerCardData( data: { id: userRow.id as number, username: (userRow.username as string | null) ?? null, + email: (userRow.email as string | null) ?? null, createdAt: (userRow.created_at as string) ?? null, lastSeen: (userRow.last_logged_at as string | null) ?? null, + lastLoggedInIp: (userRow.ip_address as string | null) ?? null, + lastLoggedInCountryCode: await countryCodeFromIp( + (userRow.ip_address as string | null) ?? null, + ), rcBalance: (userRow.rc as number | null) ?? null, ccBalance: (userRow.cc as number | null) ?? null, matchesPlayed, diff --git a/src/types/database.ts b/src/types/database.ts index 0d26cb4..96e7630 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -3,7 +3,9 @@ export type DbUser = { id: number; created_at: string; username: string | null; + email: string | null; password: string | null; + ip_address: string | null; cc: number | null; rc: number | null; last_logged_at: string | null; @@ -52,3 +54,13 @@ export type DbTransaction = { remarks: string | null; match_id: number | null; }; + +/** Mirrors `public.ping_reports` (see schemas/ping_reports.md). */ +export type DbPingReport = { + id: number; + created_at: string; + user_id: number; + match_id: number; + ip_address: string | null; + ping: number; +};