From b50377492ce8a8b95e1d2141de484fe9c0572c56 Mon Sep 17 00:00:00 2001 From: Hosteroid Date: Fri, 10 Oct 2025 14:01:19 +0300 Subject: [PATCH] Add error log management and bulk admin actions Introduces error log tracking with new ErrorLog model, controller, views, and migration. Adds admin UI for viewing, resolving, and deleting errors. Implements bulk actions for users and notification groups, refactors domain filtering/pagination, and centralizes admin access checks using Auth::requireAdmin(). --- app/Controllers/AuthController.php | 79 +-- app/Controllers/DashboardController.php | 9 +- app/Controllers/DomainController.php | 68 +-- app/Controllers/ErrorLogController.php | 208 +++++++ app/Controllers/InstallerController.php | 7 +- .../NotificationGroupController.php | 36 ++ app/Controllers/SettingsController.php | 9 +- app/Controllers/TldRegistryController.php | 30 +- app/Controllers/UserController.php | 115 +++- app/Helpers/ViewHelper.php | 163 +++++ app/Models/Domain.php | 71 +++ app/Models/ErrorLog.php | 400 +++++++++++++ app/Models/User.php | 110 ++++ app/Services/ErrorHandler.php | 315 ++++++++++ app/Views/dashboard/index.php | 6 +- app/Views/domains/index.php | 262 +++----- app/Views/domains/view.php | 6 +- app/Views/errors/500.php | 187 ++++++ app/Views/errors/admin-detail.php | 360 +++++++++++ app/Views/errors/admin-index.php | 522 ++++++++++++++++ app/Views/errors/debug.php | 565 ++++++++++++++++++ app/Views/groups/edit.php | 6 +- app/Views/groups/index.php | 104 ++++ app/Views/layout/sidebar.php | 6 +- app/Views/layout/top-nav.php | 17 +- app/Views/profile/index.php | 2 +- app/Views/tld-registry/import-logs.php | 4 +- app/Views/tld-registry/index.php | 137 +++-- app/Views/tld-registry/view.php | 6 +- app/Views/users/index.php | 177 +++++- core/Application.php | 18 +- core/Auth.php | 32 + core/Database.php | 24 +- .../015_create_error_logs_table.sql | 51 ++ database/migrations/README.md | 1 + env.example.txt | 4 +- public/index.php | 24 +- routes/web.php | 13 + 38 files changed, 3726 insertions(+), 428 deletions(-) create mode 100644 app/Controllers/ErrorLogController.php create mode 100644 app/Helpers/ViewHelper.php create mode 100644 app/Models/ErrorLog.php create mode 100644 app/Services/ErrorHandler.php create mode 100644 app/Views/errors/500.php create mode 100644 app/Views/errors/admin-detail.php create mode 100644 app/Views/errors/admin-index.php create mode 100644 app/Views/errors/debug.php create mode 100644 database/migrations/015_create_error_logs_table.sql diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 71f5378..ea488a3 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -12,13 +12,11 @@ class AuthController extends Controller { private User $userModel; private Setting $settingModel; - private $db; public function __construct() { $this->userModel = new User(); $this->settingModel = new Setting(); - $this->db = \Core\Database::getConnection(); } /** @@ -290,11 +288,8 @@ class AuthController extends Controller // Generate verification token $token = bin2hex(random_bytes(32)); - // Save token to database - $stmt = $this->db->prepare( - "UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?" - ); - $stmt->execute([$token, $userId]); + // Save token to database using model + $this->userModel->updateEmailVerificationToken($userId, $token); // Send verification email $this->sendVerificationEmail($email, $fullName, $token); @@ -303,9 +298,8 @@ class AuthController extends Controller $_SESSION['pending_verification_email'] = $email; $this->redirect('/verify-email'); } else { - // Mark as verified and log them in - $stmt = $this->db->prepare("UPDATE users SET email_verified = 1 WHERE id = ?"); - $stmt->execute([$userId]); + // Mark as verified and log them in using model + $this->userModel->markEmailAsVerified($userId); $_SESSION['success'] = 'Account created successfully! You can now log in.'; $this->redirect('/login'); @@ -344,11 +338,8 @@ class AuthController extends Controller private function verifyEmail($token) { try { - $stmt = $this->db->prepare( - "SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0" - ); - $stmt->execute([$token]); - $user = $stmt->fetch(); + // Find user by verification token using model + $user = $this->userModel->findByVerificationToken($token); if (!$user) { $this->view('auth/verify-email', [ @@ -370,11 +361,8 @@ class AuthController extends Controller return; } - // Mark email as verified - $stmt = $this->db->prepare( - "UPDATE users SET email_verified = 1, email_verification_token = NULL WHERE id = ?" - ); - $stmt->execute([$user['id']]); + // Mark email as verified using model + $this->userModel->verifyEmailByToken($user['id']); $this->view('auth/verify-email', [ 'title' => 'Email Verified', @@ -424,10 +412,8 @@ class AuthController extends Controller // Generate new verification token $token = bin2hex(random_bytes(32)); - $stmt = $this->db->prepare( - "UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?" - ); - $stmt->execute([$token, $user['id']]); + // Update verification token using model + $this->userModel->updateEmailVerificationToken($user['id'], $token); // Send verification email $this->sendVerificationEmail($user['email'], $user['full_name'], $token); @@ -507,11 +493,8 @@ class AuthController extends Controller $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour')); - // Save token to database - $stmt = $this->db->prepare( - "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)" - ); - $stmt->execute([$user['id'], $token, $expiresAt]); + // Save token to database using model + $this->userModel->createPasswordResetToken($user['id'], $token, $expiresAt); // Send reset email $this->sendPasswordResetEmail($user['email'], $user['full_name'], $token); @@ -538,12 +521,8 @@ class AuthController extends Controller return; } - // Verify token exists and is not expired - $stmt = $this->db->prepare( - "SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()" - ); - $stmt->execute([$token]); - $resetToken = $stmt->fetch(); + // Verify token exists and is not expired using model + $resetToken = $this->userModel->findPasswordResetToken($token); if (!$resetToken) { $_SESSION['error'] = 'Invalid or expired reset link'; @@ -612,12 +591,8 @@ class AuthController extends Controller } try { - // Verify token - $stmt = $this->db->prepare( - "SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()" - ); - $stmt->execute([$token]); - $resetToken = $stmt->fetch(); + // Verify token using model + $resetToken = $this->userModel->findPasswordResetToken($token); if (!$resetToken) { $_SESSION['error'] = 'Invalid or expired reset link'; @@ -628,9 +603,8 @@ class AuthController extends Controller // Update password $this->userModel->changePassword($resetToken['user_id'], $password); - // Mark token as used - $stmt = $this->db->prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?"); - $stmt->execute([$resetToken['id']]); + // Mark token as used using model + $this->userModel->markPasswordResetTokenAsUsed($resetToken['id']); $_SESSION['success'] = 'Password reset successfully! You can now log in.'; $this->redirect('/login'); @@ -651,10 +625,8 @@ class AuthController extends Controller $expiresAt = date('Y-m-d H:i:s', strtotime('+30 days')); $sessionId = session_id(); - $stmt = $this->db->prepare( - "INSERT INTO remember_tokens (user_id, session_id, token, expires_at) VALUES (?, ?, ?, ?)" - ); - $stmt->execute([$userId, $sessionId, $token, $expiresAt]); + // Create remember token using model + $this->userModel->createRememberToken($userId, $sessionId, $token, $expiresAt); // Set cookie setcookie('remember_token', $token, [ @@ -683,11 +655,8 @@ class AuthController extends Controller } try { - $stmt = $this->db->prepare( - "SELECT user_id FROM remember_tokens WHERE token = ? AND expires_at > NOW()" - ); - $stmt->execute([$token]); - $rememberToken = $stmt->fetch(); + // Find user by remember token using model + $rememberToken = $this->userModel->findByRememberToken($token); if ($rememberToken) { $user = $this->userModel->find($rememberToken['user_id']); @@ -817,8 +786,8 @@ class AuthController extends Controller $token = $_COOKIE['remember_token']; try { - $stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?"); - $stmt->execute([$token]); + // Delete remember token using model + $this->userModel->deleteRememberToken($token); } catch (\Exception $e) { // Silently fail } diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index 33c403a..3b69ca9 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -76,13 +76,12 @@ class DashboardController extends Controller $status['database'] = ['status' => 'offline', 'color' => 'red']; } - // Check WHOIS service (test with a known TLD) + // Check TLD Registry (WHOIS service) try { - $whoisService = new \App\Services\WhoisService(); - // Quick test - just check if we can discover TLD servers $tldModel = new \App\Models\TldRegistry(); - $testTld = $tldModel->find(1); // Get first TLD - if ($testTld) { + // Check if ANY TLDs exist in registry (not just id=1) + $tldStats = $tldModel->getStatistics(); + if ($tldStats['total'] > 0) { $status['whois'] = ['status' => 'active', 'color' => 'green']; } else { $status['whois'] = ['status' => 'no data', 'color' => 'yellow']; diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 1c16c38..a7c2b62 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -36,61 +36,20 @@ class DomainController extends Controller $notificationDays = $settingModel->getNotificationDays(); $expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30; - // Get all domains with groups - $domains = $this->domainModel->getAllWithGroups(); + // Prepare filters array + $filters = [ + 'search' => $search, + 'status' => $status, + 'group' => $groupId + ]; - // Apply filters - if (!empty($search)) { - $domains = array_filter($domains, function($domain) use ($search) { - return stripos($domain['domain_name'], $search) !== false || - stripos($domain['registrar'] ?? '', $search) !== false; - }); - } - - if (!empty($status)) { - $domains = array_filter($domains, function($domain) use ($status, $expiringThreshold) { - if ($status === 'expiring_soon') { - // Check if domain expires within configured threshold - if (!empty($domain['expiration_date'])) { - $daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400); - return $daysLeft <= $expiringThreshold && $daysLeft >= 0; - } - return false; - } - return $domain['status'] === $status; - }); - } - - if (!empty($groupId)) { - $domains = array_filter($domains, function($domain) use ($groupId) { - return $domain['notification_group_id'] == $groupId; - }); - } - - // Get total count after filtering - $totalDomains = count($domains); - - // Apply sorting - usort($domains, function($a, $b) use ($sortBy, $sortOrder) { - $aVal = $a[$sortBy] ?? ''; - $bVal = $b[$sortBy] ?? ''; - - $comparison = strcasecmp($aVal, $bVal); - return $sortOrder === 'desc' ? -$comparison : $comparison; - }); - - // Calculate pagination - $totalPages = ceil($totalDomains / $perPage); - $page = min($page, max(1, $totalPages)); // Ensure page is within valid range - $offset = ($page - 1) * $perPage; - - // Slice array for current page - $paginatedDomains = array_slice($domains, $offset, $perPage); + // Get filtered and paginated domains using model + $result = $this->domainModel->getFilteredPaginated($filters, $sortBy, $sortOrder, $page, $perPage, $expiringThreshold); $groups = $this->groupModel->all(); // Format domains for display - $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($paginatedDomains); + $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']); $this->view('domains/index', [ 'domains' => $formattedDomains, @@ -102,14 +61,7 @@ class DomainController extends Controller 'sort' => $sortBy, 'order' => $sortOrder ], - 'pagination' => [ - 'current_page' => $page, - 'per_page' => $perPage, - 'total' => $totalDomains, - 'total_pages' => $totalPages, - 'showing_from' => $totalDomains > 0 ? $offset + 1 : 0, - 'showing_to' => min($offset + $perPage, $totalDomains) - ], + 'pagination' => $result['pagination'], 'title' => 'Domains' ]); } diff --git a/app/Controllers/ErrorLogController.php b/app/Controllers/ErrorLogController.php new file mode 100644 index 0000000..217e04c --- /dev/null +++ b/app/Controllers/ErrorLogController.php @@ -0,0 +1,208 @@ +errorLogModel = new ErrorLog(); + } + + /** + * Display list of errors with filters + */ + public function index() + { + // Get filters from query params + $filters = [ + 'resolved' => $_GET['resolved'] ?? '', + 'type' => $_GET['type'] ?? '', + 'sort' => $_GET['sort'] ?? 'last_occurred_at', + 'order' => $_GET['order'] ?? 'desc' + ]; + + // Pagination + $page = isset($_GET['page']) ? (int)$_GET['page'] : 1; + $perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : 25; + $offset = ($page - 1) * $perPage; + + // Get total count using model + $totalErrors = $this->errorLogModel->countUniqueErrors($filters); + + // Get paginated errors using model + $errors = $this->errorLogModel->getPaginatedErrors($filters, $perPage, $offset); + + // Get statistics using model + $stats = $this->errorLogModel->getAdminStats(); + + // Pagination data + $totalPages = ceil($totalErrors / $perPage); + $pagination = [ + 'current_page' => $page, + 'total_pages' => $totalPages, + 'per_page' => $perPage, + 'total' => $totalErrors, + 'showing_from' => $totalErrors > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $totalErrors) + ]; + + $this->view('errors/admin-index', compact('errors', 'stats', 'filters', 'pagination')); + } + + /** + * Show error details + */ + public function show($params = []) + { + $errorId = $params['id'] ?? ''; + + // Get all occurrences using model + $errorOccurrences = $this->errorLogModel->getOccurrencesByErrorId($errorId); + + if (empty($errorOccurrences)) { + $_SESSION['error'] = 'Error not found'; + header('Location: /errors'); + exit; + } + + // Get the most recent occurrence for display + $error = $errorOccurrences[0]; + + // Parse JSON fields + $error['stack_trace_array'] = json_decode($error['stack_trace'], true) ?? []; + $error['request_data'] = json_decode($error['request_data'], true) ?? []; + $error['session_data'] = json_decode($error['session_data'], true) ?? []; + + $this->view('errors/admin-detail', compact('error', 'errorOccurrences')); + } + + /** + * Mark error as resolved + */ + public function markResolved($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /errors'); + exit; + } + + $this->verifyCsrf('/errors'); + + $errorId = $params['id'] ?? ''; + $notes = $_POST['notes'] ?? null; + + // Mark error as resolved using model + $this->errorLogModel->markErrorResolved($errorId, $_SESSION['user_id'], $notes); + + $_SESSION['success'] = 'Error marked as resolved'; + header('Location: /errors/' . urlencode($errorId)); + exit; + } + + /** + * Mark error as unresolved + */ + public function markUnresolved($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /errors'); + exit; + } + + $this->verifyCsrf('/errors'); + + $errorId = $params['id'] ?? ''; + + // Mark error as unresolved using model + $this->errorLogModel->markErrorUnresolved($errorId); + + $_SESSION['success'] = 'Error marked as unresolved'; + header('Location: /errors/' . urlencode($errorId)); + exit; + } + + /** + * Delete error and all its occurrences + */ + public function delete($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /errors'); + exit; + } + + $this->verifyCsrf('/errors'); + + $errorId = $params['id'] ?? ''; + + // Delete error using model + $this->errorLogModel->deleteByErrorId($errorId); + + $_SESSION['success'] = 'Error deleted successfully'; + header('Location: /errors'); + exit; + } + + /** + * Clear old resolved errors + */ + public function clearResolved() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /errors'); + exit; + } + + $this->verifyCsrf('/errors'); + + $daysOld = isset($_POST['days']) ? (int)$_POST['days'] : 30; + + // Clear old errors using model + $deletedCount = $this->errorLogModel->clearOldResolved($daysOld); + + $_SESSION['success'] = "Deleted $deletedCount resolved error(s) older than $daysOld days"; + header('Location: /errors'); + exit; + } + + /** + * Bulk delete errors + */ + public function bulkDelete() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /errors'); + exit; + } + + $this->verifyCsrf('/errors'); + + $errorIdsJson = $_POST['error_ids'] ?? '[]'; + $errorIds = json_decode($errorIdsJson, true); + + if (empty($errorIds) || !is_array($errorIds)) { + $_SESSION['error'] = 'No errors selected for deletion'; + header('Location: /errors'); + exit; + } + + $deletedCount = 0; + foreach ($errorIds as $errorId) { + if ($this->errorLogModel->deleteByErrorId($errorId)) { + $deletedCount++; + } + } + + $_SESSION['success'] = "Successfully deleted $deletedCount error(s)"; + header('Location: /errors'); + exit; + } +} diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index 38a3ba6..a0611ec 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -46,6 +46,7 @@ class InstallerController extends Controller '012_link_remember_tokens_to_sessions.sql', '013_create_user_notifications_table.sql', '014_add_captcha_settings.sql', + '015_create_error_logs_table.sql', ]; try { @@ -105,7 +106,8 @@ class InstallerController extends Controller '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', '013_create_user_notifications_table.sql', - '014_add_captcha_settings.sql' + '014_add_captcha_settings.sql', + '015_create_error_logs_table.sql' ]; } @@ -261,7 +263,8 @@ class InstallerController extends Controller '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', '013_create_user_notifications_table.sql', - '014_add_captcha_settings.sql' + '014_add_captcha_settings.sql', + '015_create_error_logs_table.sql' ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index 953880f..4d4470d 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -245,5 +245,41 @@ class NotificationGroupController extends Controller return null; } } + + /** + * Bulk delete notification groups + */ + public function bulkDelete() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/groups'); + return; + } + + $this->verifyCsrf('/groups'); + + $groupIdsJson = $_POST['group_ids'] ?? '[]'; + $groupIds = json_decode($groupIdsJson, true); + + if (empty($groupIds) || !is_array($groupIds)) { + $_SESSION['error'] = 'No groups selected for deletion'; + $this->redirect('/groups'); + return; + } + + $deletedCount = 0; + + foreach ($groupIds as $groupId) { + try { + $this->groupModel->deleteWithRelations((int)$groupId); + $deletedCount++; + } catch (\Exception $e) { + // Continue with next group + } + } + + $_SESSION['success'] = "Successfully deleted $deletedCount notification group(s)"; + $this->redirect('/groups'); + } } diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 3672a93..4aa3172 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -3,6 +3,7 @@ namespace App\Controllers; use Core\Controller; +use Core\Auth; use App\Models\Setting; class SettingsController extends Controller @@ -11,14 +12,8 @@ class SettingsController extends Controller public function __construct() { + Auth::requireAdmin(); $this->settingModel = new Setting(); - - // Ensure only admins can access settings - if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') { - $_SESSION['error'] = 'Access denied. Admin privileges required.'; - $this->redirect('/'); - exit; - } } public function index() diff --git a/app/Controllers/TldRegistryController.php b/app/Controllers/TldRegistryController.php index 2b9f85d..b2de78d 100644 --- a/app/Controllers/TldRegistryController.php +++ b/app/Controllers/TldRegistryController.php @@ -3,6 +3,7 @@ namespace App\Controllers; use Core\Controller; +use Core\Auth; use App\Models\TldRegistry; use App\Models\TldImportLog; use App\Services\TldRegistryService; @@ -22,19 +23,6 @@ class TldRegistryController extends Controller $this->tldService = new TldRegistryService(); $this->logger = new Logger('tld_registry_controller'); } - - /** - * Check if current user is admin - */ - private function requireAdmin() - { - if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') { - $_SESSION['error'] = 'Access denied. Admin privileges required.'; - $this->redirect('/tld-registry'); - exit; - } - } - /** * Display TLD registry dashboard */ @@ -91,7 +79,7 @@ class TldRegistryController extends Controller */ public function importTldList() { - $this->requireAdmin(); + Auth::requireAdmin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); @@ -129,7 +117,7 @@ class TldRegistryController extends Controller */ public function importRdap() { - $this->requireAdmin(); + Auth::requireAdmin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); @@ -167,7 +155,7 @@ class TldRegistryController extends Controller */ public function importWhois() { - $this->requireAdmin(); + Auth::requireAdmin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); @@ -209,7 +197,7 @@ class TldRegistryController extends Controller */ public function checkUpdates() { - $this->requireAdmin(); + Auth::requireAdmin(); try { $updateInfo = $this->tldService->checkForUpdates(); @@ -251,7 +239,7 @@ class TldRegistryController extends Controller */ public function startProgressiveImport() { - $this->requireAdmin(); + Auth::requireAdmin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); @@ -464,7 +452,7 @@ class TldRegistryController extends Controller */ public function bulkDelete() { - $this->requireAdmin(); + Auth::requireAdmin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/tld-registry'); @@ -512,7 +500,7 @@ class TldRegistryController extends Controller */ public function toggleActive($params = []) { - $this->requireAdmin(); + Auth::requireAdmin(); $id = $params['id'] ?? 0; $tld = $this->tldModel->find($id); @@ -536,7 +524,7 @@ class TldRegistryController extends Controller */ public function refresh($params = []) { - $this->requireAdmin(); + Auth::requireAdmin(); $id = $params['id'] ?? 0; $tld = $this->tldModel->find($id); diff --git a/app/Controllers/UserController.php b/app/Controllers/UserController.php index 7d00cb9..53c446d 100644 --- a/app/Controllers/UserController.php +++ b/app/Controllers/UserController.php @@ -12,14 +12,8 @@ class UserController extends Controller public function __construct() { + Auth::requireAdmin(); $this->userModel = new User(); - - // Ensure only admins can access user management - if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') { - $_SESSION['error'] = 'Access denied. Admin privileges required.'; - $this->redirect('/'); - exit; - } } /** @@ -378,5 +372,112 @@ class UserController extends Controller $this->redirect('/users'); } } + + /** + * Bulk toggle user status (activate or deactivate) + */ + public function bulkToggleStatus() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/users'); + return; + } + + $this->verifyCsrf('/users'); + + $userIdsJson = $_POST['user_ids'] ?? '[]'; + $userIds = json_decode($userIdsJson, true); + $action = $_POST['action'] ?? 'activate'; // 'activate' or 'deactivate' + + if (empty($userIds) || !is_array($userIds)) { + $_SESSION['error'] = 'No users selected'; + $this->redirect('/users'); + return; + } + + $newStatus = ($action === 'activate') ? 1 : 0; + $updatedCount = 0; + $skippedSelf = false; + + foreach ($userIds as $userId) { + // Prevent modifying your own account + if ($userId == Auth::id()) { + $skippedSelf = true; + continue; + } + + try { + $this->userModel->update((int)$userId, ['is_active' => $newStatus]); + $updatedCount++; + } catch (\Exception $e) { + // Continue with next user + } + } + + $message = $action === 'activate' ? "Activated $updatedCount user(s)" : "Deactivated $updatedCount user(s)"; + if ($skippedSelf) { + $message .= ' (skipped your own account)'; + } + + $_SESSION['success'] = $message; + $this->redirect('/users'); + } + + /** + * Bulk delete users + */ + public function bulkDelete() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/users'); + return; + } + + $this->verifyCsrf('/users'); + + $userIdsJson = $_POST['user_ids'] ?? '[]'; + $userIds = json_decode($userIdsJson, true); + + if (empty($userIds) || !is_array($userIds)) { + $_SESSION['error'] = 'No users selected for deletion'; + $this->redirect('/users'); + return; + } + + $deletedCount = 0; + $skippedSelf = false; + + foreach ($userIds as $userId) { + // Prevent deleting your own account + if ($userId == Auth::id()) { + $skippedSelf = true; + continue; + } + + // Prevent deleting if this is the last admin + $user = $this->userModel->find((int)$userId); + if ($user && $user['role'] === 'admin') { + $allAdmins = $this->userModel->where('role', 'admin'); + if (count($allAdmins) <= 1) { + continue; // Skip - can't delete last admin + } + } + + try { + $this->userModel->delete((int)$userId); + $deletedCount++; + } catch (\Exception $e) { + // Continue with next user + } + } + + $message = "Successfully deleted $deletedCount user(s)"; + if ($skippedSelf) { + $message .= ' (skipped your own account)'; + } + + $_SESSION['success'] = $message; + $this->redirect('/users'); + } } diff --git a/app/Helpers/ViewHelper.php b/app/Helpers/ViewHelper.php new file mode 100644 index 0000000..868a323 --- /dev/null +++ b/app/Helpers/ViewHelper.php @@ -0,0 +1,163 @@ +'; + } + + $icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down'; + return ''; + } + + /** + * Generate pagination URL + */ + public static function paginationUrl(int $page, array $filters, int $perPage): string + { + $params = $filters; + $params['page'] = $page; + $params['per_page'] = $perPage; + + $currentPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + return $currentPath . '?' . http_build_query($params); + } + + /** + * Format status badge + */ + public static function statusBadge(string $status): string + { + $statusClasses = [ + 'active' => 'bg-green-100 text-green-800 border-green-200', + 'expiring_soon' => 'bg-orange-100 text-orange-800 border-orange-200', + 'expired' => 'bg-red-100 text-red-800 border-red-200', + 'inactive' => 'bg-gray-100 text-gray-800 border-gray-200', + ]; + + $statusLabels = [ + 'active' => 'Active', + 'expiring_soon' => 'Expiring Soon', + 'expired' => 'Expired', + 'inactive' => 'Inactive', + ]; + + $class = $statusClasses[$status] ?? $statusClasses['inactive']; + $label = $statusLabels[$status] ?? ucfirst($status); + + return '' . htmlspecialchars($label) . ''; + } + + /** + * Truncate text with ellipsis + */ + public static function truncate(string $text, int $length = 50, string $suffix = '...'): string + { + if (mb_strlen($text) <= $length) { + return htmlspecialchars($text); + } + + return htmlspecialchars(mb_substr($text, 0, $length)) . $suffix; + } + + /** + * Format bytes to human readable size + */ + public static function formatBytes(int $bytes, int $precision = 2): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, $precision) . ' ' . $units[$i]; + } + + /** + * Generate breadcrumb navigation + */ + public static function breadcrumbs(array $items): string + { + $html = ''; + return $html; + } + + /** + * Generate alert message HTML + */ + public static function alert(string $type, string $message): string + { + $classes = [ + 'success' => 'bg-green-50 border-green-200 text-green-800', + 'error' => 'bg-red-50 border-red-200 text-red-800', + 'warning' => 'bg-orange-50 border-orange-200 text-orange-800', + 'info' => 'bg-blue-50 border-blue-200 text-blue-800', + ]; + + $icons = [ + 'success' => 'fa-check-circle', + 'error' => 'fa-exclamation-circle', + 'warning' => 'fa-exclamation-triangle', + 'info' => 'fa-info-circle', + ]; + + $class = $classes[$type] ?? $classes['info']; + $icon = $icons[$type] ?? $icons['info']; + + return '
+ +
' . htmlspecialchars($message) . '
+
'; + } +} + diff --git a/app/Models/Domain.php b/app/Models/Domain.php index 4e7778c..fed3679 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -139,5 +139,76 @@ class Domain extends Model return $stats; } + + /** + * Get filtered, sorted, and paginated domains + */ + public function getFilteredPaginated(array $filters, string $sortBy, string $sortOrder, int $page, int $perPage, int $expiringThreshold = 30): array + { + // Get all domains with groups + $domains = $this->getAllWithGroups(); + + // Apply search filter + if (!empty($filters['search'])) { + $domains = array_filter($domains, function($domain) use ($filters) { + return stripos($domain['domain_name'], $filters['search']) !== false || + stripos($domain['registrar'] ?? '', $filters['search']) !== false; + }); + } + + // Apply status filter + if (!empty($filters['status'])) { + $domains = array_filter($domains, function($domain) use ($filters, $expiringThreshold) { + if ($filters['status'] === 'expiring_soon') { + // Check if domain expires within configured threshold + if (!empty($domain['expiration_date'])) { + $daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400); + return $daysLeft <= $expiringThreshold && $daysLeft >= 0; + } + return false; + } + return $domain['status'] === $filters['status']; + }); + } + + // Apply group filter + if (!empty($filters['group'])) { + $domains = array_filter($domains, function($domain) use ($filters) { + return $domain['notification_group_id'] == $filters['group']; + }); + } + + // Get total count after filtering + $totalDomains = count($domains); + + // Apply sorting + usort($domains, function($a, $b) use ($sortBy, $sortOrder) { + $aVal = $a[$sortBy] ?? ''; + $bVal = $b[$sortBy] ?? ''; + + $comparison = strcasecmp($aVal, $bVal); + return $sortOrder === 'desc' ? -$comparison : $comparison; + }); + + // Calculate pagination + $totalPages = ceil($totalDomains / $perPage); + $page = min($page, max(1, $totalPages)); // Ensure page is within valid range + $offset = ($page - 1) * $perPage; + + // Slice array for current page + $paginatedDomains = array_slice($domains, $offset, $perPage); + + return [ + 'domains' => $paginatedDomains, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $totalDomains, + 'total_pages' => $totalPages, + 'showing_from' => $totalDomains > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $totalDomains) + ] + ]; + } } diff --git a/app/Models/ErrorLog.php b/app/Models/ErrorLog.php new file mode 100644 index 0000000..f125adc --- /dev/null +++ b/app/Models/ErrorLog.php @@ -0,0 +1,400 @@ +findBySimilar( + $errorData['error_type'], + $errorData['error_file'], + $errorData['error_line'] + ); + + if ($existing) { + // Update existing error + $this->incrementOccurrence($existing['id']); + return $existing['id']; + } + + // Create new error log + return $this->create($errorData); + } + + /** + * Find similar error (same type, file, line) + */ + private function findBySimilar(string $type, string $file, int $line): ?array + { + $sql = "SELECT * FROM error_logs + WHERE error_type = ? + AND error_file = ? + AND error_line = ? + AND is_resolved = FALSE + LIMIT 1"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$type, $file, $line]); + $result = $stmt->fetch(); + + return $result ?: null; + } + + /** + * Increment occurrence counter + */ + private function incrementOccurrence(int $id): void + { + $sql = "UPDATE error_logs + SET occurrences = occurrences + 1, + last_occurred_at = NOW() + WHERE id = ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$id]); + } + + /** + * Find error by error_id (unique reference) + */ + public function findByErrorId(string $errorId): ?array + { + $sql = "SELECT el.*, u.username, u.full_name, r.username as resolved_by_name + FROM error_logs el + LEFT JOIN users u ON el.user_id = u.id + LEFT JOIN users r ON el.resolved_by = r.id + WHERE el.error_id = ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$errorId]); + $result = $stmt->fetch(); + + return $result ?: null; + } + + /** + * Get recent errors with pagination + */ + public function getRecent(int $limit = 50, int $offset = 0, array $filters = []): array + { + $where = ['1=1']; + $params = []; + + // Filter by resolution status + if (isset($filters['resolved'])) { + $where[] = 'el.is_resolved = ?'; + $params[] = $filters['resolved'] ? 1 : 0; + } + + // Filter by error type + if (!empty($filters['type'])) { + $where[] = 'el.error_type LIKE ?'; + $params[] = '%' . $filters['type'] . '%'; + } + + // Filter by user + if (!empty($filters['user_id'])) { + $where[] = 'el.user_id = ?'; + $params[] = $filters['user_id']; + } + + $whereClause = implode(' AND ', $where); + + $sql = "SELECT el.*, u.username, u.full_name + FROM error_logs el + LEFT JOIN users u ON el.user_id = u.id + WHERE {$whereClause} + ORDER BY el.last_occurred_at DESC + LIMIT ? OFFSET ?"; + + $params[] = $limit; + $params[] = $offset; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * Count total errors + */ + public function count(array $filters = []): int + { + $where = ['1=1']; + $params = []; + + if (isset($filters['resolved'])) { + $where[] = 'is_resolved = ?'; + $params[] = $filters['resolved'] ? 1 : 0; + } + + if (!empty($filters['type'])) { + $where[] = 'error_type LIKE ?'; + $params[] = '%' . $filters['type'] . '%'; + } + + if (!empty($filters['user_id'])) { + $where[] = 'user_id = ?'; + $params[] = $filters['user_id']; + } + + $whereClause = implode(' AND ', $where); + + $sql = "SELECT COUNT(*) as count FROM error_logs WHERE {$whereClause}"; + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return (int)$stmt->fetch()['count']; + } + + /** + * Get error statistics + */ + public function getStats(): array + { + $sql = "SELECT + COUNT(*) as total_errors, + SUM(occurrences) as total_occurrences, + COUNT(CASE WHEN is_resolved = FALSE THEN 1 END) as unresolved, + COUNT(CASE WHEN is_resolved = TRUE THEN 1 END) as resolved, + COUNT(CASE WHEN occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 END) as last_24h, + COUNT(CASE WHEN occurred_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 END) as last_7d + FROM error_logs"; + + $stmt = $this->db->query($sql); + return $stmt->fetch(); + } + + /** + * Mark error as resolved + */ + public function resolve(int $id, int $resolvedBy, ?string $notes = null): bool + { + return $this->update($id, [ + 'is_resolved' => true, + 'resolved_at' => date('Y-m-d H:i:s'), + 'resolved_by' => $resolvedBy, + 'notes' => $notes + ]); + } + + /** + * Delete old resolved errors + */ + public function deleteOldResolved(int $daysOld = 30): int + { + $sql = "DELETE FROM error_logs + WHERE is_resolved = TRUE + AND resolved_at < DATE_SUB(NOW(), INTERVAL ? DAY)"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$daysOld]); + return $stmt->rowCount(); + } + + /** + * Get most frequent errors + */ + public function getMostFrequent(int $limit = 10): array + { + $sql = "SELECT el.*, u.username, u.full_name + FROM error_logs el + LEFT JOIN users u ON el.user_id = u.id + WHERE el.is_resolved = FALSE + ORDER BY el.occurrences DESC, el.last_occurred_at DESC + LIMIT ?"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$limit]); + return $stmt->fetchAll(); + } + + /** + * Get paginated errors with filters for admin panel + */ + public function getPaginatedErrors(array $filters, int $perPage, int $offset): array + { + $where = []; + $params = []; + + if ($filters['resolved'] !== '') { + $where[] = 'is_resolved = ?'; + $params[] = (int)$filters['resolved']; + } + + if (!empty($filters['type'])) { + $where[] = 'error_type LIKE ?'; + $params[] = '%' . $filters['type'] . '%'; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $sortColumn = $filters['sort']; + $sortOrder = strtoupper($filters['order']) === 'DESC' ? 'DESC' : 'ASC'; + + $query = " + SELECT + error_id, + error_type, + error_message, + error_file, + error_line, + is_resolved, + MIN(occurred_at) as occurred_at, + MAX(occurred_at) as last_occurred_at, + COUNT(*) as occurrences + FROM error_logs + $whereClause + GROUP BY error_id + ORDER BY $sortColumn $sortOrder + LIMIT ? OFFSET ? + "; + + $stmt = $this->db->prepare($query); + $stmt->execute([...$params, $perPage, $offset]); + return $stmt->fetchAll(); + } + + /** + * Count total unique errors with filters + */ + public function countUniqueErrors(array $filters): int + { + $where = []; + $params = []; + + if ($filters['resolved'] !== '') { + $where[] = 'is_resolved = ?'; + $params[] = (int)$filters['resolved']; + } + + if (!empty($filters['type'])) { + $where[] = 'error_type LIKE ?'; + $params[] = '%' . $filters['type'] . '%'; + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + $query = "SELECT COUNT(DISTINCT error_id) as total FROM error_logs $whereClause"; + $stmt = $this->db->prepare($query); + $stmt->execute($params); + return (int)$stmt->fetch()['total']; + } + + /** + * Get all occurrences of a specific error + */ + public function getOccurrencesByErrorId(string $errorId): array + { + $stmt = $this->db->prepare(" + SELECT * FROM error_logs + WHERE error_id = ? + ORDER BY occurred_at DESC + "); + $stmt->execute([$errorId]); + return $stmt->fetchAll(); + } + + /** + * Get admin statistics + */ + public function getAdminStats(): array + { + // Total unique errors + $stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs"); + $totalErrors = $stmt->fetch()['total']; + + // Unresolved errors + $stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE is_resolved = 0"); + $unresolved = $stmt->fetch()['total']; + + // Errors in last 24h + $stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)"); + $last24h = $stmt->fetch()['total']; + + // Total occurrences + $stmt = $this->db->query("SELECT COUNT(*) as total FROM error_logs"); + $totalOccurrences = $stmt->fetch()['total']; + + return [ + 'total_errors' => $totalErrors, + 'unresolved' => $unresolved, + 'last_24h' => $last24h, + 'total_occurrences' => $totalOccurrences + ]; + } + + /** + * Mark all occurrences of an error as resolved + */ + public function markErrorResolved(string $errorId, int $userId, ?string $notes): bool + { + $stmt = $this->db->prepare(" + UPDATE error_logs + SET is_resolved = 1, + resolved_at = NOW(), + resolved_by = ?, + resolution_notes = ? + WHERE error_id = ? + "); + + return $stmt->execute([$userId, $notes, $errorId]); + } + + /** + * Mark all occurrences of an error as unresolved + */ + public function markErrorUnresolved(string $errorId): bool + { + $stmt = $this->db->prepare(" + UPDATE error_logs + SET is_resolved = 0, + resolved_at = NULL, + resolved_by = NULL, + resolution_notes = NULL + WHERE error_id = ? + "); + + return $stmt->execute([$errorId]); + } + + /** + * Delete all occurrences of an error + */ + public function deleteByErrorId(string $errorId): bool + { + $stmt = $this->db->prepare("DELETE FROM error_logs WHERE error_id = ?"); + return $stmt->execute([$errorId]); + } + + /** + * Clear old resolved errors + */ + public function clearOldResolved(int $daysOld): int + { + $stmt = $this->db->prepare(" + DELETE FROM error_logs + WHERE is_resolved = 1 + AND resolved_at < DATE_SUB(NOW(), INTERVAL ? DAY) + "); + $stmt->execute([$daysOld]); + return $stmt->rowCount(); + } +} + diff --git a/app/Models/User.php b/app/Models/User.php index a1b2f94..d591c4b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -144,5 +144,115 @@ class User extends Model $stmt->execute($params); return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['total']; } + + /** + * Update email verification token + */ + public function updateEmailVerificationToken(int $userId, string $token): bool + { + $stmt = $this->db->prepare( + "UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?" + ); + return $stmt->execute([$token, $userId]); + } + + /** + * Mark email as verified + */ + public function markEmailAsVerified(int $userId): bool + { + $stmt = $this->db->prepare("UPDATE users SET email_verified = 1 WHERE id = ?"); + return $stmt->execute([$userId]); + } + + /** + * Verify email by clearing token + */ + public function verifyEmailByToken(int $userId): bool + { + $stmt = $this->db->prepare( + "UPDATE users SET email_verified = 1, email_verification_token = NULL WHERE id = ?" + ); + return $stmt->execute([$userId]); + } + + /** + * Find user by email verification token + */ + public function findByVerificationToken(string $token): ?array + { + $stmt = $this->db->prepare( + "SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0" + ); + $stmt->execute([$token]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Create password reset token + */ + public function createPasswordResetToken(int $userId, string $token, string $expiresAt): bool + { + $stmt = $this->db->prepare( + "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)" + ); + return $stmt->execute([$userId, $token, $expiresAt]); + } + + /** + * Find valid password reset token + */ + public function findPasswordResetToken(string $token): ?array + { + $stmt = $this->db->prepare( + "SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()" + ); + $stmt->execute([$token]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Mark password reset token as used + */ + public function markPasswordResetTokenAsUsed(int $tokenId): bool + { + $stmt = $this->db->prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?"); + return $stmt->execute([$tokenId]); + } + + /** + * Create remember token + */ + public function createRememberToken(int $userId, string $sessionId, string $token, string $expiresAt): bool + { + $stmt = $this->db->prepare( + "INSERT INTO remember_tokens (user_id, session_id, token, expires_at) VALUES (?, ?, ?, ?)" + ); + return $stmt->execute([$userId, $sessionId, $token, $expiresAt]); + } + + /** + * Find user by remember token + */ + public function findByRememberToken(string $token): ?array + { + $stmt = $this->db->prepare( + "SELECT user_id FROM remember_tokens WHERE token = ? AND expires_at > NOW()" + ); + $stmt->execute([$token]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Delete remember token + */ + public function deleteRememberToken(string $token): bool + { + $stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?"); + return $stmt->execute([$token]); + } } diff --git a/app/Services/ErrorHandler.php b/app/Services/ErrorHandler.php new file mode 100644 index 0000000..f06dd5c --- /dev/null +++ b/app/Services/ErrorHandler.php @@ -0,0 +1,315 @@ +logger = new Logger('errors'); + // Default to development if APP_ENV not set (show debug info for config errors) + $this->isDevelopment = ($_ENV['APP_ENV'] ?? 'development') === 'development'; + + // Initialize ErrorLog model if database is available + try { + $this->errorLogModel = new ErrorLog(); + } catch (\Exception $e) { + // Database not available, will only use file logging + // Don't use error_log as it might fail too + } + } + + /** + * Handle an exception + */ + public function handleException(\Throwable $exception): void + { + $errorData = $this->captureError($exception); + + // Log to file + $this->logToFile($errorData); + + // Log to database if available + $dbErrorId = $this->logToDatabase($errorData); + + // Display error page + $this->displayError($errorData, $dbErrorId); + } + + /** + * Handle PHP errors (convert to exception) + */ + public function handleError(int $errno, string $errstr, string $errfile, int $errline): bool + { + // Don't handle suppressed errors (@) + if (!(error_reporting() & $errno)) { + return false; + } + + // Ignore certain non-critical errors during error handling itself + if (error_reporting() === 0) { + return false; + } + + // Convert to ErrorException and handle it + $exception = new \ErrorException($errstr, 0, $errno, $errfile, $errline); + $this->handleException($exception); + + return true; + } + + /** + * Handle fatal errors on shutdown + */ + public function handleShutdown(): void + { + $error = error_get_last(); + + if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) { + $exception = new \ErrorException( + $error['message'], + 0, + $error['type'], + $error['file'], + $error['line'] + ); + + $this->handleException($exception); + } + } + + /** + * Capture complete error context + */ + private function captureError(\Throwable $exception): array + { + // Generate unique error ID + $errorId = $this->generateErrorId(); + + // Sanitize request data (remove passwords, tokens, etc.) + $requestData = $this->sanitizeArray(array_merge($_GET, $_POST)); + + // Sanitize session data + $sessionData = $this->sanitizeArray($_SESSION ?? []); + + return [ + 'error_id' => $errorId, + 'error_type' => get_class($exception), + 'error_message' => $exception->getMessage(), + 'error_file' => $exception->getFile(), + 'error_line' => $exception->getLine(), + 'stack_trace' => $exception->getTraceAsString(), + 'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI', + 'request_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A', + 'request_data' => json_encode($requestData), + 'user_id' => $_SESSION['user_id'] ?? null, + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown', + 'ip_address' => $this->getIpAddress(), + 'session_data' => json_encode($sessionData), + 'php_version' => PHP_VERSION, + 'memory_usage' => memory_get_usage(true), + 'occurred_at' => date('Y-m-d H:i:s') + ]; + } + + /** + * Generate unique error ID for reference + */ + private function generateErrorId(): string + { + return strtoupper(substr(md5(uniqid('error_', true)), 0, 12)); + } + + /** + * Get client IP address + */ + private function getIpAddress(): string + { + $headers = [ + 'HTTP_CF_CONNECTING_IP', // Cloudflare + 'HTTP_X_FORWARDED_FOR', // Proxy + 'HTTP_X_REAL_IP', // Nginx + 'REMOTE_ADDR' // Direct + ]; + + foreach ($headers as $header) { + if (!empty($_SERVER[$header])) { + $ip = $_SERVER[$header]; + // Handle multiple IPs (X-Forwarded-For) + if (strpos($ip, ',') !== false) { + $ip = trim(explode(',', $ip)[0]); + } + return $ip; + } + } + + return 'Unknown'; + } + + /** + * Sanitize array to remove sensitive data + */ + private function sanitizeArray(array $data): array + { + $sensitive = ['password', 'password_confirm', 'current_password', 'new_password', + 'token', 'csrf_token', 'api_key', 'secret', 'bot_token', + 'mail_password', 'webhook_url', 'captcha_secret_key']; + + $sanitized = []; + foreach ($data as $key => $value) { + $lowerKey = strtolower($key); + $isSensitive = false; + + foreach ($sensitive as $pattern) { + if (strpos($lowerKey, $pattern) !== false) { + $isSensitive = true; + break; + } + } + + if ($isSensitive) { + $sanitized[$key] = '***REDACTED***'; + } elseif (is_array($value)) { + $sanitized[$key] = $this->sanitizeArray($value); + } else { + $sanitized[$key] = $value; + } + } + + return $sanitized; + } + + /** + * Log error to file + */ + private function logToFile(array $errorData): void + { + $this->logger->separator('ERROR CAPTURED'); + $this->logger->critical('Error occurred', [ + 'error_id' => $errorData['error_id'], + 'type' => $errorData['error_type'], + 'message' => $errorData['error_message'], + 'file' => $errorData['error_file'], + 'line' => $errorData['error_line'], + 'uri' => $errorData['request_uri'], + 'user_id' => $errorData['user_id'], + 'ip' => $errorData['ip_address'] + ]); + + // Log stack trace separately for readability + $this->logger->error('Stack Trace', ['trace' => $errorData['stack_trace']]); + $this->logger->separator('END ERROR'); + } + + /** + * Log error to database + */ + private function logToDatabase(array $errorData): ?int + { + if ($this->errorLogModel === null) { + return null; + } + + try { + return $this->errorLogModel->logError($errorData); + } catch (\Exception $e) { + // Database logging failed, continue with file logging only + error_log("Failed to log error to database: " . $e->getMessage()); + return null; + } + } + + /** + * Display appropriate error page + */ + private function displayError(array $errorData, ?int $dbErrorId): void + { + // Set HTTP status code + http_response_code(500); + + // Clean any output buffers + while (ob_get_level() > 0) { + ob_end_clean(); + } + + // Extract variables for view (avoid using extract() which might fail) + $error_id = $errorData['error_id']; + $error_type = $errorData['error_type']; + $error_message = $errorData['error_message']; + $error_file = $errorData['error_file']; + $error_line = $errorData['error_line']; + $stack_trace = $errorData['stack_trace']; + $request_method = $errorData['request_method']; + $request_uri = $errorData['request_uri']; + $user_agent = $errorData['user_agent']; + $ip_address = $errorData['ip_address']; + $php_version = $errorData['php_version']; + $memory_usage = $errorData['memory_usage']; + $occurred_at = $errorData['occurred_at']; + $user_info = $this->getUserInfo($errorData['user_id']); + $request_data = json_decode($errorData['request_data'], true); + $session_data = json_decode($errorData['session_data'], true); + + // Display debug page in development, clean 500 in production + if ($this->isDevelopment) { + require __DIR__ . '/../Views/errors/debug.php'; + } else { + require __DIR__ . '/../Views/errors/500.php'; + } + + exit; + } + + /** + * Get user info for error context + */ + private function getUserInfo(?int $userId): ?array + { + if ($userId === null) { + return null; + } + + return [ + 'id' => $userId, + 'username' => $_SESSION['username'] ?? 'Unknown', + 'role' => $_SESSION['role'] ?? 'guest', + 'email' => $_SESSION['email'] ?? 'Unknown' + ]; + } + + /** + * Static helper to register global handlers + */ + public static function register(): self + { + $handler = new self(); + + // Set exception handler + set_exception_handler([$handler, 'handleException']); + + // Set error handler (converts errors to exceptions) + set_error_handler([$handler, 'handleError']); + + // Set shutdown handler (catch fatal errors) + register_shutdown_function([$handler, 'handleShutdown']); + + return $handler; + } +} + diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index a1801eb..cced667 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -161,11 +161,11 @@ ob_start(); Create Group - -
+ +
- WHOIS Lookup + WHOIS Lookup
diff --git a/app/Views/domains/index.php b/app/Views/domains/index.php index a0fe5ef..fe8d124 100644 --- a/app/Views/domains/index.php +++ b/app/Views/domains/index.php @@ -28,81 +28,27 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's ?> -
-
- - -
- -
- -
- - - - - -
- - - - Bulk Add - - - - Add Domain - -
+
+ +
+ + + + + +
+ + + + Bulk Add + + + + Add Domain +
@@ -150,6 +96,59 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
+ + +
@@ -184,8 +183,8 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's - -
- + + @@ -231,8 +230,8 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's $statusIcon = $domain['statusIcon']; ?>
- + +
@@ -328,7 +327,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
- +
@@ -440,76 +439,47 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's - + @@ -167,8 +167,8 @@ ob_start();
-
-
+
+
diff --git a/app/Views/errors/500.php b/app/Views/errors/500.php new file mode 100644 index 0000000..4241f2f --- /dev/null +++ b/app/Views/errors/500.php @@ -0,0 +1,187 @@ + + + + + + 500 - Internal Server Error + + + + + + + + + + +
+
+ +
+ +
+ + +

500

+

Internal Server Error

+

+ Oops! Something went wrong on our end. We're working to fix the issue. +

+ + + +
+
+
+ +
+
+

Error Reference ID:

+
+ + + + +
+

+ + Please include this ID when reporting the issue +

+
+
+
+ + + +
+ + + Go to Dashboard + + + +
+ + + + + +
+

+ + If this problem persists, please contact your system administrator + + and provide the error reference ID above. + + . + +

+
+
+ + +
+

+ + Domain Monitor © +

+
+
+ + + + + diff --git a/app/Views/errors/admin-detail.php b/app/Views/errors/admin-detail.php new file mode 100644 index 0000000..6f03597 --- /dev/null +++ b/app/Views/errors/admin-detail.php @@ -0,0 +1,360 @@ + + + +
+ + + Back to Error Logs + + +
+ +
+ + +
+ + + + + +
+
+ + +
+
+
+
+ +
+
+
+

+ + + + Resolved + + + + + Unresolved + + +
+

+
+
+ + + +
+
+ + occurrence +
+
+ + Last: +
+
+
+
+
+ + +
+
+
+

File

+

+
+
+

Line

+

+
+
+
+
+ + + +
+
+ +
+

Resolved

+
+

Date:

+ +

Notes:

+ +
+
+
+
+ + + +
+
+ +
+ + +
+ +
+ +
+ $trace): ?> +
+
+
+ +
+
+ +

+ + line +

+ + +

+ + + + () +

+ +
+
+
+ +
+ +

No stack trace available

+ +
+ + + + + + + + + +
+
+ + +
+

System Information

+
+
+

PHP Version

+

+
+
+

Memory Usage

+

+
+
+

First Occurred

+

+
+
+
+ + + + + diff --git a/app/Views/errors/admin-index.php b/app/Views/errors/admin-index.php new file mode 100644 index 0000000..61231a8 --- /dev/null +++ b/app/Views/errors/admin-index.php @@ -0,0 +1,522 @@ +'; + } + $icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down'; + return ''; +} + +// Get current filters +$currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_occurred_at', 'order' => 'desc']; +?> + + +
+ +
+
+
+

Total Errors

+

+
+
+ +
+
+
+ + +
+
+
+

Unresolved

+

+
+
+ +
+
+
+ + +
+
+
+

Last 24h

+

+
+
+ +
+
+
+ + +
+
+
+

Occurrences

+

+
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + Clear + +
+
+ +
+
+ + + + + +
+
+ Showing to + of + error(s) +
+ +
+ + + + + + + + +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + Error + + Location + + Occurrences + + Last Occurred + + Status + Actions
+ + +
+
+ +
+
+
+ + +
+

+

+ +

+
+
+
+
+

+ +

+

+ + Line +

+
+
+ + + × + + +
+ + +
+
+ + + + Resolved + + + + + Unresolved + + + +
+ + + + + + + +
+
+
+ +
+
+ +
+

No Errors Found

+

+ + No errors match your filter criteria. + + Great! Your application is running smoothly. + +

+
+ +
+ + + 1): ?> +
+
+ Page of + +
+ +
+ + + 1): ?> + + + + + Previous + + + + 1) { + echo '1'; + if ($start > 2) echo '...'; + } + + for ($i = $start; $i <= $end; $i++) { + if ($i == $currentPage) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + if ($end < $totalPages) { + if ($end < $totalPages - 1) echo '...'; + echo '' . $totalPages . ''; + } + ?> + + + + Next + + + + + +
+
+ + + + + + diff --git a/app/Views/errors/debug.php b/app/Views/errors/debug.php new file mode 100644 index 0000000..2df6062 --- /dev/null +++ b/app/Views/errors/debug.php @@ -0,0 +1,565 @@ + + + + + + Debug Error - <?= htmlspecialchars($error_type ?? 'Application Error') ?> + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+

Debug Mode

+

+ + Development Environment - Detailed Error Information +

+
+
+ +
+
+
+ +
+ + +
+
+ +
+
+ +
+
+

+ +

+

+ + +
+

+ + Error Location +

+
+
+ File: + + + +
+
+ Line: + +
+
+
+
+
+ + +
+ +
+
+

Error ID

+ +
+ +

+ + Use for bug reports +

+
+ + +
+

Request

+
+

+ +

+ + + +
+
+ + +
+

User

+ +

+

+ + +

+ +

+ + Guest (Not logged in) +

+ +
+ + +
+

System

+
+

+ + PHP +

+

+ + MB +

+

+ + +

+
+
+
+
+
+ + +
+ + +
+ + + +
+
+
+

+ + Stack Trace +

+ +
+
+
+
+ $line) { + if (trim($line)) { + echo '
'; + echo '' . str_pad($index, 2, '0', STR_PAD_LEFT) . ''; + echo '' . htmlspecialchars($line) . ''; + echo '
'; + } + } + ?> +
+
+
+ + + + +
+ + +
+ + +
+ + +
+ + +
+
+

+ + Request Details +

+
+
+
+
+ Method + +
+
+ URI + + + +
+
+ IP Address + + + +
+
+ User Agent + + + +
+
+
+
+ + +
+
+

+ + System Information +

+
+
+
+
+ PHP Version + +
+
+ Memory Usage + MB +
+
+ Peak Memory + MB +
+
+ Timestamp + +
+
+
+
+ + + +
+ + +
+ + +
+
+ + +
+
+
+
+ +
+
+
+

Debug Mode Active

+

+ This detailed error page is only shown in development mode. In production, users will see a clean error page with just the error ID. +

+
+ + + Go to Dashboard + + +
+
+
+
+ +
+ + +
+
+

+ + Domain Monitor © • Development Mode +

+
+
+ + + + + + diff --git a/app/Views/groups/edit.php b/app/Views/groups/edit.php index 0a0ddfc..9507c6e 100644 --- a/app/Views/groups/edit.php +++ b/app/Views/groups/edit.php @@ -79,14 +79,16 @@ ob_start(); 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack']; - $colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'purple']; + $iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab']; + $colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal']; $icon = $icons[$channel['channel_type']] ?? 'fa-bell'; + $iconClass = $iconClasses[$channel['channel_type']] ?? 'fas'; $color = $colors[$channel['channel_type']] ?? 'gray'; ?>
- +
diff --git a/app/Views/groups/index.php b/app/Views/groups/index.php index f5c8b7d..8ef620e 100644 --- a/app/Views/groups/index.php +++ b/app/Views/groups/index.php @@ -31,6 +31,25 @@ ob_start();
+ + +
@@ -39,6 +58,9 @@ ob_start(); + @@ -49,6 +71,9 @@ ob_start(); +
+ + Group Name Description Channels
+ +
@@ -150,6 +175,85 @@ ob_start();
+ +

Tools

- + WHOIS Lookup @@ -63,6 +63,10 @@ Users + + + Error Logs +
diff --git a/app/Views/layout/top-nav.php b/app/Views/layout/top-nav.php index b6c76e5..8bdff0f 100644 --- a/app/Views/layout/top-nav.php +++ b/app/Views/layout/top-nav.php @@ -129,9 +129,7 @@ @@ -141,9 +139,16 @@

- - Online - +
+ + + + + + + Online + +
diff --git a/app/Views/profile/index.php b/app/Views/profile/index.php index 931f25c..4389951 100644 --- a/app/Views/profile/index.php +++ b/app/Views/profile/index.php @@ -21,7 +21,7 @@ ob_start();

@

- + diff --git a/app/Views/tld-registry/import-logs.php b/app/Views/tld-registry/import-logs.php index d7df8bc..58945d0 100644 --- a/app/Views/tld-registry/import-logs.php +++ b/app/Views/tld-registry/import-logs.php @@ -74,8 +74,8 @@ ob_start();

-
- +
+
diff --git a/app/Views/tld-registry/index.php b/app/Views/tld-registry/index.php index 6de076e..edc131b 100644 --- a/app/Views/tld-registry/index.php +++ b/app/Views/tld-registry/index.php @@ -28,45 +28,39 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc' ?> -
-
- - - -
-

- - View-only mode. Contact admin to import or modify TLD data. -

-
- - -
- -
+ +
+ + + Import Logs + +
+ + + +
+
+ + + +
+
+ +
+
+ +

+ View-only mode. Contact admin to import or modify TLD data. +

+
@@ -103,8 +97,8 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'

With RDAP

-
- +
+
@@ -200,22 +194,25 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
- - -
+ + +