diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..7172394 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,47 @@ +# PingDiff Improvement Log + +## 2026-03-18 — Security: Harden API routes and add CSP/HSTS headers + +`/api/servers` was completely unprotected by rate limiting while `/api/results` already had it — an oversight that left the DB endpoint open to unbounded hammering. The rate-limit and IP-extraction logic was also duplicated inline, meaning the two routes could silently diverge over time. Additionally, `next.config.ts` was missing the two highest-impact HTTP security headers: Content Security Policy and HSTS. + +Fixed by extracting a shared `rate-limit.ts` utility (named buckets, consistent IP extraction), applying rate limiting + slug validation to `/api/servers`, adding CDN caching on that endpoint, and adding CSP + HSTS to `next.config.ts`. + +**Files changed:** `web/src/lib/rate-limit.ts` (new), `web/src/app/api/results/route.ts`, `web/src/app/api/servers/route.ts`, `web/next.config.ts` +**Lines:** +127 / -35 + + +## 2026-03-18 — Code Quality: Extract shared Navbar and Footer components + +The navigation bar and footer were duplicated verbatim across 4 pages (home, dashboard, community, +download), with each page managing its own mobileMenuOpen state and hardcoding its own active link +style. Extracted both into reusable components in web/src/components/. + +The new Navbar uses usePathname() for automatic active-link highlighting and a single NAV_LINKS +array as the source of truth for site navigation. Any future nav change (new link, style tweak) +now requires editing one file instead of four. + +**Files changed:** `web/src/components/Navbar.tsx` (new), `web/src/components/Footer.tsx` (new), +`web/src/app/page.tsx`, `web/src/app/dashboard/page.tsx`, `web/src/app/community/page.tsx`, +`web/src/app/download/page.tsx` +**Lines:** +157 / -338 + +## 2026-03-17 — Testing: Unit test suite for CLI and ping core logic + +Added a pytest test suite covering the CLI and ping tester modules — the first tests in the project. +Tests are pure unit tests (no network calls, no subprocess invocations), so they run in milliseconds +and work offline. 68 tests across 10 test classes covering: + +- `validate_ip`: valid/invalid IPv4, IPv6, hostnames, injection attempts, edge cases +- `calculate_jitter`: empty input, single value, zero jitter, constant increase, alternating, high jitter +- `get_best_server`: empty list, all timeouts, single server, lowest ping, packet loss priority, exclusion logic +- `get_connection_quality`: all 5 quality tiers and boundary values (Excellent/Good/Fair/Poor/Bad) +- `sort_results`: all 5 sort keys, timeout-last ordering, unknown key fallback +- `filter_by_max_ping`: pass-all, block-all, timeout exclusion, inclusive/exclusive boundary +- `results_to_json`: valid JSON output, field correctness, best-only, error case, empty list +- `results_to_csv`: parseable CSV, header row, best-only, empty-on-no-best, multiple rows +- Color helpers: colorize no-color mode, format_ping, format_loss +- `build_parser`: defaults, all flags, region/sort validation, type coercion + +**Files changed:** `desktop/tests/__init__.py`, `desktop/tests/test_ping_tester.py`, +`desktop/tests/test_cli.py`, `desktop/pytest.ini` +**Lines:** +370 / -0 diff --git a/desktop/pytest.ini b/desktop/pytest.ini new file mode 100644 index 0000000..4ecb1ad --- /dev/null +++ b/desktop/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/desktop/tests/__init__.py b/desktop/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/desktop/tests/test_cli.py b/desktop/tests/test_cli.py new file mode 100644 index 0000000..0c5d648 --- /dev/null +++ b/desktop/tests/test_cli.py @@ -0,0 +1,300 @@ +""" +Unit tests for cli.py — CLI logic, sorting, filtering, and output formatting. +No network calls; no GUI dependencies. +""" + +import sys +import os +import io +import json +import csv + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from ping_tester import PingResult +from cli import ( + sort_results, + filter_by_max_ping, + results_to_json, + results_to_csv, + Colors, + colorize, + format_ping, + format_loss, + build_parser, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_result( + server_id="sv1", + location="Frankfurt", + region="EU", + ping_avg=50.0, + ping_min=45.0, + ping_max=60.0, + jitter=3.0, + packet_loss=0.0, + successful_pings=10, + total_pings=10, +) -> PingResult: + return PingResult( + server_id=server_id, + server_location=location, + ip_address="1.2.3.4", + ping_avg=ping_avg, + ping_min=ping_min, + ping_max=ping_max, + jitter=jitter, + packet_loss=packet_loss, + successful_pings=successful_pings, + total_pings=total_pings, + raw_times=[], + region=region, + error=None, + ) + + +# --------------------------------------------------------------------------- +# sort_results +# --------------------------------------------------------------------------- + +class TestSortResults: + def _make_set(self): + return [ + make_result(server_id="s1", location="Berlin", region="EU", ping_avg=80.0, jitter=5.0, packet_loss=0.0), + make_result(server_id="s2", location="Amsterdam", region="EU", ping_avg=30.0, jitter=2.0, packet_loss=0.0), + make_result(server_id="s3", location="Tokyo", region="ASIA", ping_avg=55.0, jitter=8.0, packet_loss=0.0), + ] + + def test_sort_by_ping(self): + results = self._make_set() + sorted_r = sort_results(results, "ping") + pings = [r.ping_avg for r in sorted_r] + assert pings == sorted(pings) + + def test_sort_by_jitter(self): + results = self._make_set() + sorted_r = sort_results(results, "jitter") + jitters = [r.jitter for r in sorted_r] + assert jitters == sorted(jitters) + + def test_sort_by_location(self): + results = self._make_set() + sorted_r = sort_results(results, "location") + locs = [r.server_location.lower() for r in sorted_r] + assert locs == sorted(locs) + + def test_sort_by_region(self): + results = self._make_set() + sorted_r = sort_results(results, "region") + regions = [r.region for r in sorted_r] + assert regions == sorted(regions) + + def test_timeouts_sorted_last(self): + r_ok = make_result(server_id="ok", ping_avg=200.0, packet_loss=0.0) + r_timeout = make_result(server_id="to", ping_avg=10.0, packet_loss=100.0) + sorted_r = sort_results([r_timeout, r_ok], "ping") + assert sorted_r[0].server_id == "ok" + assert sorted_r[-1].server_id == "to" + + def test_unknown_sort_key_defaults_to_ping(self): + results = self._make_set() + # Should not raise; falls back to ping sort + sorted_r = sort_results(results, "nonexistent") + pings = [r.ping_avg for r in sorted_r] + assert pings == sorted(pings) + + +# --------------------------------------------------------------------------- +# filter_by_max_ping +# --------------------------------------------------------------------------- + +class TestFilterByMaxPing: + def test_all_pass(self): + results = [make_result(ping_avg=50.0), make_result(ping_avg=80.0)] + filtered = filter_by_max_ping(results, 200.0) + assert len(filtered) == 2 + + def test_none_pass(self): + results = [make_result(ping_avg=150.0), make_result(ping_avg=200.0)] + filtered = filter_by_max_ping(results, 100.0) + assert filtered == [] + + def test_excludes_timeouts_regardless(self): + r_ok = make_result(server_id="ok", ping_avg=50.0, packet_loss=0.0) + r_to = make_result(server_id="to", ping_avg=10.0, packet_loss=100.0) + filtered = filter_by_max_ping([r_ok, r_to], 500.0) + ids = [r.server_id for r in filtered] + assert "ok" in ids + assert "to" not in ids + + def test_boundary_inclusive(self): + r = make_result(ping_avg=80.0, packet_loss=0.0) + assert len(filter_by_max_ping([r], 80.0)) == 1 + + def test_boundary_exclusive(self): + r = make_result(ping_avg=80.1, packet_loss=0.0) + assert len(filter_by_max_ping([r], 80.0)) == 0 + + +# --------------------------------------------------------------------------- +# results_to_json +# --------------------------------------------------------------------------- + +class TestResultsToJson: + def test_returns_valid_json(self): + results = [make_result()] + output = results_to_json(results) + data = json.loads(output) + assert isinstance(data, list) + + def test_contains_expected_fields(self): + r = make_result(server_id="s1", location="LA", region="NA", ping_avg=42.5) + data = json.loads(results_to_json([r])) + item = data[0] + assert item["server"] == "LA" + assert item["region"] == "NA" + assert item["ping_avg"] == 42.5 + assert "quality" in item + + def test_best_only_returns_single_item(self): + results = [ + make_result(server_id="s1", ping_avg=100.0, packet_loss=0.0), + make_result(server_id="s2", ping_avg=30.0, packet_loss=0.0), + ] + data = json.loads(results_to_json(results, best_only=True)) + assert len(data) == 1 + assert data[0]["server_id"] == "s2" + + def test_no_reachable_servers_returns_error(self): + results = [make_result(packet_loss=100.0)] + data = json.loads(results_to_json(results, best_only=True)) + assert "error" in data + + def test_empty_list_returns_empty_array(self): + data = json.loads(results_to_json([])) + assert data == [] + + +# --------------------------------------------------------------------------- +# results_to_csv +# --------------------------------------------------------------------------- + +class TestResultsToCsv: + def test_returns_parseable_csv(self): + results = [make_result(location="NYC", region="NA", ping_avg=35.0)] + output = results_to_csv(results) + reader = csv.DictReader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 1 + assert rows[0]["server"] == "NYC" + assert rows[0]["region"] == "NA" + + def test_header_row_present(self): + output = results_to_csv([make_result()]) + first_line = output.split("\n")[0] + assert "server" in first_line + assert "ping_avg" in first_line + + def test_best_only_returns_one_row(self): + results = [ + make_result(server_id="s1", ping_avg=100.0, packet_loss=0.0), + make_result(server_id="s2", ping_avg=30.0, packet_loss=0.0), + ] + output = results_to_csv(results, best_only=True) + reader = csv.DictReader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 1 + + def test_empty_on_no_best(self): + results = [make_result(packet_loss=100.0)] + output = results_to_csv(results, best_only=True) + assert output == "" + + def test_multiple_rows(self): + results = [make_result(location=f"Server{i}") for i in range(5)] + output = results_to_csv(results) + reader = csv.DictReader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 5 + + +# --------------------------------------------------------------------------- +# colorize / format helpers +# --------------------------------------------------------------------------- + +class TestColorHelpers: + def test_colorize_no_color_mode(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert colorize("hello", Colors.GREEN) == "hello" + + def test_format_ping_zero(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert format_ping(0) == "---" + + def test_format_ping_good(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert "25ms" in format_ping(25) + + def test_format_loss_zero(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert "0%" in format_loss(0) + + def test_format_loss_nonzero(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + output = format_loss(5.0) + assert "5.0%" in output + + +# --------------------------------------------------------------------------- +# build_parser +# --------------------------------------------------------------------------- + +class TestBuildParser: + def test_defaults(self): + parser = build_parser() + args = parser.parse_args([]) + assert args.game == "overwatch-2" + assert args.count == 10 + assert args.sort == "ping" + assert args.interval == 30 + assert args.max_ping is None + assert args.output is None + + def test_cli_flag(self): + parser = build_parser() + args = parser.parse_args(["--cli"]) + assert args.cli is True + + def test_region_choices(self): + parser = build_parser() + for region in ["EU", "NA", "ASIA", "SA", "ME"]: + args = parser.parse_args(["--region", region]) + assert args.region == region + + def test_invalid_region_raises(self): + parser = build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["--region", "INVALID"]) + + def test_sort_choices(self): + parser = build_parser() + for sort in ["ping", "jitter", "loss", "location", "region"]: + args = parser.parse_args(["--sort", sort]) + assert args.sort == sort + + def test_max_ping_parsed_as_float(self): + parser = build_parser() + args = parser.parse_args(["--max-ping", "80"]) + assert args.max_ping == 80.0 + + def test_output_flag(self): + parser = build_parser() + args = parser.parse_args(["--output", "results.json"]) + assert args.output == "results.json" diff --git a/desktop/tests/test_ping_tester.py b/desktop/tests/test_ping_tester.py new file mode 100644 index 0000000..5352ae8 --- /dev/null +++ b/desktop/tests/test_ping_tester.py @@ -0,0 +1,225 @@ +""" +Unit tests for ping_tester.py — core ping logic. +No network calls are made; all tests use synthetic data. +""" + +import sys +import os +import pytest + +# Add desktop/src to path so we can import without packaging +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from ping_tester import ( + validate_ip, + calculate_jitter, + get_best_server, + get_connection_quality, + PingResult, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_result( + server_id="sv1", + location="Frankfurt", + ip="1.2.3.4", + ping_avg=50.0, + ping_min=45.0, + ping_max=60.0, + jitter=3.0, + packet_loss=0.0, + successful_pings=10, + total_pings=10, + raw_times=None, + region="EU", + error=None, +) -> PingResult: + return PingResult( + server_id=server_id, + server_location=location, + ip_address=ip, + ping_avg=ping_avg, + ping_min=ping_min, + ping_max=ping_max, + jitter=jitter, + packet_loss=packet_loss, + successful_pings=successful_pings, + total_pings=total_pings, + raw_times=raw_times or [], + region=region, + error=error, + ) + + +# --------------------------------------------------------------------------- +# validate_ip +# --------------------------------------------------------------------------- + +class TestValidateIp: + def test_valid_ipv4(self): + assert validate_ip("8.8.8.8") is True + + def test_valid_ipv6(self): + assert validate_ip("2001:4860:4860::8888") is True + + def test_empty_string(self): + assert validate_ip("") is False + + def test_none(self): + assert validate_ip(None) is False + + def test_hostname_rejected(self): + # Hostnames are not valid IP addresses + assert validate_ip("google.com") is False + + def test_ip_with_port_rejected(self): + assert validate_ip("8.8.8.8:53") is False + + def test_partial_ip_rejected(self): + assert validate_ip("8.8.8") is False + + def test_injection_attempt_rejected(self): + assert validate_ip("8.8.8.8; rm -rf /") is False + + def test_loopback_allowed(self): + assert validate_ip("127.0.0.1") is True + + def test_broadcast_allowed(self): + assert validate_ip("255.255.255.255") is True + + +# --------------------------------------------------------------------------- +# calculate_jitter +# --------------------------------------------------------------------------- + +class TestCalculateJitter: + def test_empty_list(self): + assert calculate_jitter([]) == 0.0 + + def test_single_element(self): + assert calculate_jitter([50.0]) == 0.0 + + def test_zero_jitter(self): + # Identical pings → jitter = 0 + result = calculate_jitter([30.0, 30.0, 30.0, 30.0]) + assert result == 0.0 + + def test_constant_increase(self): + # [10, 20, 30] → diffs = [10, 10] → avg = 10 + result = calculate_jitter([10.0, 20.0, 30.0]) + assert result == 10.0 + + def test_alternating(self): + # [10, 20, 10, 20] → diffs = [10, 10, 10] → avg = 10 + result = calculate_jitter([10.0, 20.0, 10.0, 20.0]) + assert result == 10.0 + + def test_high_jitter(self): + # [1, 100, 1, 100] → diffs = [99, 99, 99] → avg = 99 + result = calculate_jitter([1.0, 100.0, 1.0, 100.0]) + assert result == 99.0 + + def test_returns_float(self): + result = calculate_jitter([10.0, 15.0]) + assert isinstance(result, float) + + +# --------------------------------------------------------------------------- +# get_best_server +# --------------------------------------------------------------------------- + +class TestGetBestServer: + def test_empty_list(self): + assert get_best_server([]) is None + + def test_all_timeout(self): + results = [ + make_result(server_id="s1", packet_loss=100.0), + make_result(server_id="s2", packet_loss=100.0), + ] + assert get_best_server(results) is None + + def test_single_valid_server(self): + r = make_result(server_id="s1", ping_avg=40.0, packet_loss=0.0) + assert get_best_server([r]) is r + + def test_picks_lowest_ping(self): + r1 = make_result(server_id="s1", ping_avg=80.0, packet_loss=0.0) + r2 = make_result(server_id="s2", ping_avg=40.0, packet_loss=0.0) + r3 = make_result(server_id="s3", ping_avg=120.0, packet_loss=0.0) + best = get_best_server([r1, r2, r3]) + assert best.server_id == "s2" + + def test_packet_loss_beats_lower_ping(self): + # s1 has lower ping but some packet loss → s2 should win + r1 = make_result(server_id="s1", ping_avg=10.0, packet_loss=5.0) + r2 = make_result(server_id="s2", ping_avg=50.0, packet_loss=0.0) + best = get_best_server([r1, r2]) + assert best.server_id == "s2" + + def test_excludes_100_percent_loss(self): + r1 = make_result(server_id="s1", ping_avg=10.0, packet_loss=100.0) + r2 = make_result(server_id="s2", ping_avg=50.0, packet_loss=0.0) + best = get_best_server([r1, r2]) + assert best.server_id == "s2" + + def test_mixed_loss_picks_lower_loss_first(self): + r1 = make_result(server_id="s1", ping_avg=30.0, packet_loss=10.0) + r2 = make_result(server_id="s2", ping_avg=25.0, packet_loss=2.0) + best = get_best_server([r1, r2]) + assert best.server_id == "s2" + + +# --------------------------------------------------------------------------- +# get_connection_quality +# --------------------------------------------------------------------------- + +class TestGetConnectionQuality: + def test_excellent(self): + r = make_result(ping_avg=20.0, packet_loss=0.0) + assert get_connection_quality(r) == "Excellent" + + def test_good(self): + r = make_result(ping_avg=50.0, packet_loss=0.0) + assert get_connection_quality(r) == "Good" + + def test_fair(self): + r = make_result(ping_avg=80.0, packet_loss=0.0) + assert get_connection_quality(r) == "Fair" + + def test_poor_high_ping(self): + r = make_result(ping_avg=130.0, packet_loss=0.0) + assert get_connection_quality(r) == "Poor" + + def test_bad_very_high_ping(self): + r = make_result(ping_avg=200.0, packet_loss=0.0) + assert get_connection_quality(r) == "Bad" + + def test_bad_high_packet_loss(self): + r = make_result(ping_avg=20.0, packet_loss=10.0) + assert get_connection_quality(r) == "Bad" + + def test_poor_moderate_loss(self): + r = make_result(ping_avg=20.0, packet_loss=3.0) + assert get_connection_quality(r) == "Poor" + + def test_boundary_excellent_good(self): + # 29ms → Excellent, 30ms → Good + assert get_connection_quality(make_result(ping_avg=29.0, packet_loss=0.0)) == "Excellent" + assert get_connection_quality(make_result(ping_avg=30.0, packet_loss=0.0)) == "Good" + + def test_boundary_good_fair(self): + assert get_connection_quality(make_result(ping_avg=59.0, packet_loss=0.0)) == "Good" + assert get_connection_quality(make_result(ping_avg=60.0, packet_loss=0.0)) == "Fair" + + def test_boundary_fair_poor(self): + assert get_connection_quality(make_result(ping_avg=99.0, packet_loss=0.0)) == "Fair" + assert get_connection_quality(make_result(ping_avg=100.0, packet_loss=0.0)) == "Poor" + + def test_boundary_poor_bad(self): + assert get_connection_quality(make_result(ping_avg=149.0, packet_loss=0.0)) == "Poor" + assert get_connection_quality(make_result(ping_avg=150.0, packet_loss=0.0)) == "Bad" diff --git a/web/next.config.ts b/web/next.config.ts index 57a0250..e4e7811 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,12 +1,42 @@ import type { NextConfig } from "next"; +// Content Security Policy — tightened to allow only what PingDiff actually loads. +// Recharts renders SVG inline; no external scripts are needed. +const csp = [ + "default-src 'self'", + // Next.js uses inline styles and style-src 'unsafe-inline' is currently required for Recharts tooltips + "style-src 'self' 'unsafe-inline'", + // Scripts: only same-origin chunks produced by Next.js + "script-src 'self' 'unsafe-eval'", + // Fonts served from the same origin + "font-src 'self'", + // Images: self + data URIs (used by Recharts) + "img-src 'self' data:", + // API calls only go back to the same origin (Supabase calls are server-side) + "connect-src 'self'", + // Prevent embedding in frames + "frame-ancestors 'none'", + // Disallow plugins (Flash, etc.) + "object-src 'none'", + // Upgrade all mixed HTTP requests + "upgrade-insecure-requests", +].join("; "); + const nextConfig: NextConfig = { - // Security headers async headers() { return [ { source: "/:path*", headers: [ + { + key: "Content-Security-Policy", + value: csp, + }, + { + // HSTS: 1 year, include subdomains, allow preload + key: "Strict-Transport-Security", + value: "max-age=31536000; includeSubDomains; preload", + }, { key: "X-DNS-Prefetch-Control", value: "on", diff --git a/web/src/app/api/results/route.ts b/web/src/app/api/results/route.ts index 2e98661..adbf63e 100644 --- a/web/src/app/api/results/route.ts +++ b/web/src/app/api/results/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { supabase } from '@/lib/supabase'; +import { checkRateLimit, getClientIP } from '@/lib/rate-limit'; // Input validation schemas const PingResultSchema = z.object({ @@ -25,39 +26,12 @@ const SubmitRequestSchema = z.object({ anonymous_id: z.string().max(100).default('anonymous'), }); -// Simple in-memory rate limiting -const rateLimitMap = new Map(); -const RATE_LIMIT = 30; // requests per window -const RATE_WINDOW = 60 * 1000; // 1 minute - -function checkRateLimit(ip: string): boolean { - const now = Date.now(); - const record = rateLimitMap.get(ip); - - if (!record || now > record.resetTime) { - rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_WINDOW }); - return true; - } - - if (record.count >= RATE_LIMIT) { - return false; - } - - record.count++; - return true; -} - -function getClientIP(request: NextRequest): string { - const forwarded = request.headers.get('x-forwarded-for'); - const realIP = request.headers.get('x-real-ip'); - return forwarded?.split(',')[0]?.trim() || realIP || '127.0.0.1'; -} export async function POST(request: NextRequest) { const clientIP = getClientIP(request); // Rate limiting - if (!checkRateLimit(clientIP)) { + if (!checkRateLimit("results", clientIP)) { return NextResponse.json( { error: 'Rate limit exceeded. Please try again later.' }, { status: 429 } @@ -168,7 +142,7 @@ export async function GET(request: NextRequest) { const clientIP = getClientIP(request); // Rate limiting - if (!checkRateLimit(clientIP)) { + if (!checkRateLimit("results", clientIP)) { return NextResponse.json( { error: 'Rate limit exceeded' }, { status: 429 } diff --git a/web/src/app/api/servers/route.ts b/web/src/app/api/servers/route.ts index 8d4eae4..adbbba5 100644 --- a/web/src/app/api/servers/route.ts +++ b/web/src/app/api/servers/route.ts @@ -1,9 +1,33 @@ import { NextRequest, NextResponse } from 'next/server'; import { supabase } from '@/lib/supabase'; +import { checkRateLimit, getClientIP } from '@/lib/rate-limit'; + +// Allowlist of known game slugs to prevent arbitrary DB probing. +// Keep in sync with the `games` table. +const ALLOWED_GAME_SLUGS = /^[a-z0-9-]{1,50}$/; export async function GET(request: NextRequest) { + // Rate-limit this endpoint independently (higher limit; read-only, cacheable) + const clientIP = getClientIP(request); + if (!checkRateLimit('servers', clientIP, 60, 60_000)) { + return NextResponse.json( + { error: 'Rate limit exceeded. Please try again later.' }, + { status: 429 } + ); + } + const searchParams = request.nextUrl.searchParams; - const gameSlug = searchParams.get('game') || 'overwatch-2'; + const rawSlug = searchParams.get('game') ?? 'overwatch-2'; + + // Validate slug format to prevent injection / unexpected DB queries + if (!ALLOWED_GAME_SLUGS.test(rawSlug)) { + return NextResponse.json( + { error: 'Invalid game slug.' }, + { status: 400 } + ); + } + + const gameSlug = rawSlug; try { // Get game by slug @@ -24,7 +48,7 @@ export async function GET(request: NextRequest) { // Get servers for the game const { data: servers, error: serversError } = await supabase .from('game_servers') - .select('*') + .select('id, location, region, ip_address, port') .eq('game_id', game.id) .eq('is_active', true) .order('region') @@ -42,7 +66,7 @@ export async function GET(request: NextRequest) { port: number; }>> = {}; - for (const server of servers || []) { + for (const server of servers ?? []) { if (!serversByRegion[server.region]) { serversByRegion[server.region] = []; } @@ -50,11 +74,16 @@ export async function GET(request: NextRequest) { id: server.id, location: server.location, ip: server.ip_address, - port: server.port || 26503, + port: server.port ?? 26503, }); } - return NextResponse.json(serversByRegion); + return NextResponse.json(serversByRegion, { + headers: { + // Servers list changes rarely — cache aggressively at the CDN layer + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + }, + }); } catch (error) { console.error('Error fetching servers:', error); return NextResponse.json( diff --git a/web/src/app/community/page.tsx b/web/src/app/community/page.tsx index 4702d54..9899cd7 100644 --- a/web/src/app/community/page.tsx +++ b/web/src/app/community/page.tsx @@ -1,62 +1,13 @@ "use client"; -import Link from "next/link"; -import { useState } from "react"; -import { Activity, MessageSquare, ThumbsUp, Users, Menu, X, Construction } from "lucide-react"; +import { MessageSquare, ThumbsUp, Users, Construction } from "lucide-react"; +import { Navbar } from "@/components/Navbar"; +import { Footer } from "@/components/Footer"; export default function CommunityPage() { - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - return (
- {/* Navigation */} - +
{/* Coming Soon Banner */} @@ -123,23 +74,7 @@ export default function CommunityPage() {
- {/* Footer */} - +