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'
- - -
+ + +