mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: polish items #26-29 — mobile CSS, error pages, rate limiting, session manager
#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)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
* Sessions endpoint — admin session management
|
||||
* GET sessions/list — all active sessions with user info
|
||||
* DELETE sessions/revoke — {session_id} revoke one session
|
||||
* DELETE sessions/revoke-user — {user_id} revoke all sessions for a user
|
||||
* DELETE sessions/revoke-all — revoke all sessions except current
|
||||
*/
|
||||
|
||||
Auth::getInstance()->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),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user