From 88e98b4727a9058a1d473ccf64923845a0409c16 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 8 Jun 2026 00:50:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20polish=20items=20#26-29=20=E2=80=94=20m?= =?UTF-8?q?obile=20CSS,=20error=20pages,=20rate=20limiting,=20session=20ma?= =?UTF-8?q?nager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #26 Mobile responsive: - Hamburger button (SVG) in topbar for all three panels (admin/user/reseller) - Sidebar overlay div for click-outside-to-close on mobile - nova.js: DOMContentLoaded toggle handler with overlay and auto-close on nav click - nova.css: sidebar-overlay, page-header, panel/panel-header, table, btn-success/warning/danger/secondary/xs, badge-muted; mobile media query shows toggle, fixes stats-grid/modal/panel-header layout #27 Custom error pages: - /errors/404.php and /errors/500.php with NovaCPX dark theme matching panel design - Apache ErrorDocument 400/401/403/404/500/503 for ports 8880/8881/8882 with Alias /errors #28 API rate limiting: - api_rate_limits table (migration 004) with per-IP per-bucket counters - api/index.php: 10 req/min for auth endpoint, 120 req/min for all others - Returns X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers - Returns 429 Too Many Requests when exceeded; rate limit failure is non-fatal #29 Session Manager: - sessions.php endpoint: list/revoke/revoke-user/revoke-all - Admin panel Sessions page: table of active sessions with user, role, IP, browser, timestamps - Revoke single session, revoke all for user, revoke all sessions (self-evicts) --- db/migrations/004_rate_limits.sql | 8 +++ panel/api/endpoints/sessions.php | 57 ++++++++++++++++++++++ panel/api/index.php | 34 +++++++++++++ panel/public/admin/index.php | 8 ++- panel/public/assets/css/nova.css | 81 +++++++++++++++++++++++++++++++ panel/public/assets/js/admin.js | 70 +++++++++++++++++++++++++- panel/public/assets/js/nova.js | 19 ++++++++ panel/public/errors/404.php | 39 +++++++++++++++ panel/public/errors/500.php | 39 +++++++++++++++ panel/public/reseller/index.php | 1 + panel/public/user/index.php | 1 + 11 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 db/migrations/004_rate_limits.sql create mode 100644 panel/api/endpoints/sessions.php create mode 100644 panel/public/errors/404.php create mode 100644 panel/public/errors/500.php diff --git a/db/migrations/004_rate_limits.sql b/db/migrations/004_rate_limits.sql new file mode 100644 index 0000000..2adc7f5 --- /dev/null +++ b/db/migrations/004_rate_limits.sql @@ -0,0 +1,8 @@ +-- Migration 004: API rate limiting table +CREATE TABLE IF NOT EXISTS api_rate_limits ( + ip VARCHAR(45) NOT NULL, + endpoint VARCHAR(64) NOT NULL DEFAULT 'api', + hits INT UNSIGNED NOT NULL DEFAULT 1, + window_start INT UNSIGNED NOT NULL, + PRIMARY KEY (ip, endpoint) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/panel/api/endpoints/sessions.php b/panel/api/endpoints/sessions.php new file mode 100644 index 0000000..3680e9d --- /dev/null +++ b/panel/api/endpoints/sessions.php @@ -0,0 +1,57 @@ +require('admin'); +$body = json_decode(file_get_contents('php://input'), true) ?? []; +$db = DB::getInstance(); +$me = Auth::getInstance()->user(); +$method = $_SERVER['REQUEST_METHOD']; + +match (true) { + + $action === 'list' && $method === 'GET' => (function() use ($db) { + $rows = $db->fetchAll( + "SELECT s.id, s.user_id, s.ip_address, s.user_agent, s.created_at, s.expires_at, + u.username, u.email, u.role + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.expires_at > NOW() + ORDER BY s.created_at DESC + LIMIT 200" + ) ?: []; + Response::json(['success' => true, 'data' => $rows]); + })(), + + $action === 'revoke' && $method === 'DELETE' => (function() use ($db, $body) { + $sid = trim($body['session_id'] ?? ''); + if (!$sid) Response::error('session_id required', 400); + $db->execute("DELETE FROM sessions WHERE id = ?", [$sid]); + Response::json(['success' => true]); + })(), + + $action === 'revoke-user' && $method === 'DELETE' => (function() use ($db, $body) { + $uid = (int)($body['user_id'] ?? 0); + if (!$uid) Response::error('user_id required', 400); + $count = $db->execute("DELETE FROM sessions WHERE user_id = ?", [$uid]); + Response::json(['success' => true, 'data' => ['revoked' => $count]]); + })(), + + $action === 'revoke-all' && $method === 'DELETE' => (function() use ($db, $me, $body) { + // Keep current session if provided + $keepId = $body['keep_session'] ?? null; + if ($keepId) { + $db->execute("DELETE FROM sessions WHERE id != ?", [hash('sha256', $keepId)]); + } else { + $db->execute("DELETE FROM sessions"); + } + Response::json(['success' => true]); + })(), + + default => Response::error('Not found', 404), +}; diff --git a/panel/api/index.php b/panel/api/index.php index 1b7839e..7b84ec6 100644 --- a/panel/api/index.php +++ b/panel/api/index.php @@ -56,4 +56,38 @@ if (!file_exists($endpointFile)) { Response::error("Unknown endpoint: $endpoint", 404); } + + +// #28 Rate limiting — per-IP, per-endpoint bucket +(function() use ($endpoint) { + $db = DB::getInstance(); + $ip = $_SERVER["REMOTE_ADDR"] ?? "0.0.0.0"; + $now = time(); + $window = 60; + $limit = $endpoint === "auth" ? 10 : 120; + $bucket = $endpoint === "auth" ? "auth" : "api"; + try { + $row = $db->fetchOne("SELECT hits, window_start FROM api_rate_limits WHERE ip=? AND endpoint=?", [$ip, $bucket]); + if ($row && ($now - (int)$row["window_start"]) < $window) { + $hits = (int)$row["hits"] + 1; + $db->execute("UPDATE api_rate_limits SET hits=? WHERE ip=? AND endpoint=?", [$hits, $ip, $bucket]); + } else { + $hits = 1; + $db->execute("INSERT INTO api_rate_limits (ip, endpoint, hits, window_start) VALUES (?,?,1,?) ON DUPLICATE KEY UPDATE hits=1, window_start=VALUES(window_start)", [$ip, $bucket, $now]); + } + $reset = ($row ? (int)$row["window_start"] : $now) + $window; + $remaining = max(0, $limit - $hits); + header("X-RateLimit-Limit: {$limit}"); + header("X-RateLimit-Remaining: {$remaining}"); + header("X-RateLimit-Reset: {$reset}"); + if ($hits > $limit) { + http_response_code(429); + echo json_encode(["success"=>false,"message"=>"Too many requests. Try again in " . ($reset - $now) . " seconds.","errors"=>[]]); + exit; + } + } catch (Throwable $e) { + novacpx_log("warn", "rate limit error: " . $e->getMessage()); + } +})(); + require $endpointFile; diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php index c16e457..504d9df 100644 --- a/panel/public/admin/index.php +++ b/panel/public/admin/index.php @@ -14,6 +14,7 @@