settingModel = new Setting(); $this->logger = new Logger('settings'); } public function index() { $settings = $this->settingModel->getAllAsKeyValue(); $appSettings = $this->settingModel->getAppSettings(); $emailSettings = $this->settingModel->getEmailSettings(); $captchaSettings = $this->settingModel->getCaptchaSettings(); $twoFactorSettings = $this->settingModel->getTwoFactorSettings(); $isolationSettings = $this->getIsolationSettings(); $updateSettings = $this->settingModel->getUpdateSettings(); // Predefined notification day options $notificationPresets = [ 'minimal' => [ 'label' => 'Minimal (30, 7, 1 days)', 'value' => '30,7,1' ], 'standard' => [ 'label' => 'Standard (60, 30, 21, 14, 7, 5, 3, 2, 1 days)', 'value' => '60,30,21,14,7,5,3,2,1' ], 'frequent' => [ 'label' => 'Frequent (90, 60, 45, 30, 21, 14, 10, 7, 5, 3, 2, 1 days)', 'value' => '90,60,45,30,21,14,10,7,5,3,2,1' ], 'business' => [ 'label' => 'Business Focused (60, 30, 14, 7, 3, 1 days)', 'value' => '60,30,14,7,3,1' ], 'conservative' => [ 'label' => 'Conservative (30, 15, 7, 3, 1 days)', 'value' => '30,15,7,3,1' ], 'custom' => [ 'label' => 'Custom', 'value' => 'custom' ] ]; // Check interval presets $checkIntervalPresets = [ ['label' => 'Every 6 hours', 'value' => 6], ['label' => 'Every 12 hours', 'value' => 12], ['label' => 'Daily (24 hours)', 'value' => 24], ['label' => 'Every 2 days (48 hours)', 'value' => 48], ['label' => 'Weekly (168 hours)', 'value' => 168] ]; // Status notification triggers $statusTriggers = $this->settingModel->getNotificationStatusTriggers(); // Timezone lists for the Application tab $popularTimezones = [ 'UTC' => 'UTC', 'America/New_York' => 'Eastern Time (US)', 'America/Chicago' => 'Central Time (US)', 'America/Denver' => 'Mountain Time (US)', 'America/Los_Angeles' => 'Pacific Time (US)', 'Europe/London' => 'London', 'Europe/Paris' => 'Paris', 'Asia/Tokyo' => 'Tokyo', 'Australia/Sydney' => 'Sydney' ]; $allTimezones = timezone_identifiers_list(); // Determine which notification preset is selected $currentNotificationDays = $settings['notification_days_before'] ?? '30,15,7,3,1'; $selectedPreset = 'custom'; foreach ($notificationPresets as $key => $preset) { if ($preset['value'] === $currentNotificationDays) { $selectedPreset = $key; break; } } // Cron path for System tab $cronPath = realpath(defined('PATH_ROOT') ? PATH_ROOT . 'cron/check_domains.php' : __DIR__ . '/../../cron/check_domains.php') ?: 'cron/check_domains.php'; // Cached update state for Updates tab $cachedUpdateAvailable = false; $cachedUpdateData = null; $currentVer = $appSettings['app_version'] ?? '0'; $latestVer = $updateSettings['latest_available_version'] ?? null; $updateChannel = $updateSettings['update_channel'] ?? 'stable'; $commitsBehind = (int)($updateSettings['commits_behind_count'] ?? 0); $installedSha = $updateSettings['installed_commit_sha'] ?? ''; $remoteSha = $updateSettings['latest_remote_sha'] ?? ''; if ($installedSha !== '' && $remoteSha !== '' && str_starts_with($installedSha, $remoteSha)) { $commitsBehind = 0; } if ($latestVer && version_compare($latestVer, $currentVer, '>')) { $cachedUpdateAvailable = true; $cachedUpdateData = [ 'available' => true, 'type' => 'release', 'current_version' => $currentVer, 'latest_version' => $latestVer, 'release_notes' => $updateSettings['latest_release_notes'] ?? '', 'release_url' => $updateSettings['latest_release_url'] ?? '', 'published_at' => $updateSettings['latest_release_published_at'] ?? null, 'channel' => $updateChannel, ]; } elseif ($updateChannel === 'latest' && $commitsBehind > 0) { $cachedUpdateAvailable = true; $cachedUpdateData = [ 'available' => true, 'type' => 'hotfix', 'current_version' => $currentVer, 'commits_behind' => $commitsBehind, 'commit_messages' => [], 'channel' => $updateChannel, ]; } // Rollback availability $rollbackAvailable = !empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path']); // Cron staleness: show warning if last run is overdue $intervalHours = (int)($settings['check_interval_hours'] ?? 24); $domainStaleThreshold = $intervalHours * 1.5; // e.g. 36h for 24h interval $dnsStaleThreshold = 24; // DNS cron runs every 6h, 24h = overdue $domainCronStale = false; $dnsCronStale = false; if (!empty($settings['last_check_run'])) { $hoursSince = (time() - strtotime($settings['last_check_run'])) / 3600; $domainCronStale = $hoursSince > $domainStaleThreshold; } if (!empty($settings['last_dns_check_run'])) { $hoursSince = (time() - strtotime($settings['last_dns_check_run'])) / 3600; $dnsCronStale = $hoursSince > $dnsStaleThreshold; } $this->view('settings/index', [ 'settings' => $settings, 'appSettings' => $appSettings, 'emailSettings' => $emailSettings, 'captchaSettings' => $captchaSettings, 'twoFactorSettings' => $twoFactorSettings, 'isolationSettings' => $isolationSettings, 'updateSettings' => $updateSettings, 'notificationPresets' => $notificationPresets, 'checkIntervalPresets' => $checkIntervalPresets, 'statusTriggers' => $statusTriggers, 'popularTimezones' => $popularTimezones, 'allTimezones' => $allTimezones, 'selectedPreset' => $selectedPreset, 'cronPath' => $cronPath, 'cachedUpdateAvailable' => $cachedUpdateAvailable, 'cachedUpdateData' => $cachedUpdateData, 'rollbackAvailable' => $rollbackAvailable, 'domainCronStale' => $domainCronStale, 'dnsCronStale' => $dnsCronStale, 'title' => 'Settings' ]); } public function update() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings#monitoring'); try { // Update notification days $notificationPreset = $_POST['notification_preset'] ?? 'standard'; if ($notificationPreset === 'custom') { // Custom days entered by user $customDays = trim($_POST['custom_notification_days'] ?? ''); if (empty($customDays)) { $_SESSION['error'] = 'Please enter notification days for custom preset'; $this->redirect('/settings#monitoring'); return; } // Validate custom days (comma-separated integers) $daysArray = array_map('trim', explode(',', $customDays)); $daysArray = array_filter($daysArray, function($day) { return is_numeric($day) && $day > 0; }); if (empty($daysArray)) { $_SESSION['error'] = 'Invalid notification days format. Use comma-separated numbers (e.g., 30,15,7,1)'; $this->redirect('/settings#monitoring'); return; } // Sort in descending order rsort($daysArray, SORT_NUMERIC); $notificationDays = implode(',', $daysArray); } else { // Use preset value $notificationDays = $_POST['notification_days_before'] ?? '30,15,7,3,1'; } // Update check interval $checkInterval = (int)($_POST['check_interval_hours'] ?? 24); if ($checkInterval < 1 || $checkInterval > 720) { // Max 30 days $_SESSION['error'] = 'Check interval must be between 1 and 720 hours'; $this->redirect('/settings#monitoring'); return; } // Update status notification triggers $statusTriggers = $_POST['notification_status_triggers'] ?? []; if (!is_array($statusTriggers)) { $statusTriggers = []; } // Save settings $this->settingModel->setValue('notification_days_before', $notificationDays); $this->settingModel->setValue('check_interval_hours', $checkInterval); $this->settingModel->updateNotificationStatusTriggers($statusTriggers); $_SESSION['success'] = 'Settings updated successfully'; $this->redirect('/settings#monitoring'); } catch (\Exception $e) { $_SESSION['error'] = 'Failed to update settings: ' . $e->getMessage(); $this->redirect('/settings#monitoring'); } } public function testCron() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings'); // Update last check run time to show the test worked $this->settingModel->updateLastCheckRun(); $_SESSION['info'] = 'Test notification sent (feature coming soon). Last check time updated.'; $this->redirect('/settings'); } public function clearLogs() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings#maintenance'); try { // Clear notification logs older than 30 days $deleted = $this->settingModel->clearOldNotificationLogs(30); $_SESSION['success'] = "Cleared $deleted old notification log(s)"; $this->redirect('/settings#maintenance'); } catch (\Exception $e) { $_SESSION['error'] = 'Failed to clear logs: ' . $e->getMessage(); $this->redirect('/settings#maintenance'); } } public function updateApp() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings#app'); try { $appSettings = [ 'app_name' => trim($_POST['app_name'] ?? 'Domain Monitor'), 'app_url' => trim($_POST['app_url'] ?? 'http://localhost:8000'), 'app_timezone' => trim($_POST['app_timezone'] ?? 'UTC') ]; // Validate app_name if (empty($appSettings['app_name'])) { $_SESSION['error'] = 'Application name is required'; $this->redirect('/settings#app'); return; } // Validate app_url if (empty($appSettings['app_url']) || !filter_var($appSettings['app_url'], FILTER_VALIDATE_URL)) { $_SESSION['error'] = 'Please enter a valid application URL'; $this->redirect('/settings#app'); return; } // Validate timezone $validTimezones = timezone_identifiers_list(); if (!in_array($appSettings['app_timezone'], $validTimezones)) { $_SESSION['error'] = 'Invalid timezone selected'; $this->redirect('/settings#app'); return; } // Update app settings $this->settingModel->updateAppSettings($appSettings); // Update registration settings $registrationEnabled = isset($_POST['registration_enabled']) ? '1' : '0'; $requireEmailVerification = isset($_POST['require_email_verification']) ? '1' : '0'; $this->settingModel->setValue('registration_enabled', $registrationEnabled); $this->settingModel->setValue('require_email_verification', $requireEmailVerification); // Update domain view template $viewTemplate = trim($_POST['domain_view_template'] ?? 'detailed'); if (!in_array($viewTemplate, ['legacy', 'detailed'])) { $viewTemplate = 'detailed'; } $this->settingModel->setValue('domain_view_template', $viewTemplate); $_SESSION['success'] = 'Application settings updated successfully'; $this->redirect('/settings#app'); } catch (\Exception $e) { $_SESSION['error'] = 'Failed to update application settings: ' . $e->getMessage(); $this->redirect('/settings#app'); } } public function updateEmail() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings#email'); try { $port = (int)trim($_POST['mail_port'] ?? '2525'); $encryption = trim($_POST['mail_encryption'] ?? 'tls'); // Auto-detect encryption based on port if not explicitly set $originalEncryption = $encryption; if (empty($encryption) || $encryption === 'tls') { if ($port === 465) { $encryption = 'ssl'; // Port 465 should use SSL $this->logger->info('Auto-detected SSL encryption for port 465', [ 'port' => $port, 'original_encryption' => $originalEncryption, 'detected_encryption' => $encryption ]); } elseif ($port === 587) { $encryption = 'tls'; // Port 587 should use TLS $this->logger->info('Auto-detected TLS encryption for port 587', [ 'port' => $port, 'original_encryption' => $originalEncryption, 'detected_encryption' => $encryption ]); } // For other ports, keep the user's selection } $emailSettings = [ 'mail_host' => trim($_POST['mail_host'] ?? ''), 'mail_port' => $port, 'mail_username' => trim($_POST['mail_username'] ?? ''), 'mail_password' => trim($_POST['mail_password'] ?? ''), 'mail_encryption' => $encryption, 'mail_from_address' => trim($_POST['mail_from_address'] ?? ''), 'mail_from_name' => trim($_POST['mail_from_name'] ?? 'Domain Monitor') ]; // Validate required fields if (empty($emailSettings['mail_host'])) { $_SESSION['error'] = 'Mail host is required'; $this->redirect('/settings#email'); return; } if (empty($emailSettings['mail_from_address']) || !filter_var($emailSettings['mail_from_address'], FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Please enter a valid from email address'; $this->redirect('/settings#email'); return; } // Validate port if (!is_numeric($emailSettings['mail_port']) || $emailSettings['mail_port'] < 1 || $emailSettings['mail_port'] > 65535) { $_SESSION['error'] = 'Please enter a valid port number (1-65535)'; $this->redirect('/settings#email'); return; } $this->settingModel->updateEmailSettings($emailSettings); $_SESSION['success'] = 'Email settings updated successfully'; $this->redirect('/settings#email'); } catch (\Exception $e) { $_SESSION['error'] = 'Failed to update email settings: ' . $e->getMessage(); $this->redirect('/settings#email'); } } public function updateCaptcha() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings#security'); try { $captchaProvider = trim($_POST['captcha_provider'] ?? 'disabled'); $captchaSiteKey = trim($_POST['captcha_site_key'] ?? ''); $captchaSecretKey = trim($_POST['captcha_secret_key'] ?? ''); $recaptchaV3Threshold = trim($_POST['recaptcha_v3_score_threshold'] ?? '0.5'); // Validate provider $validProviders = ['disabled', 'recaptcha_v2', 'recaptcha_v3', 'turnstile']; if (!in_array($captchaProvider, $validProviders)) { $_SESSION['error'] = 'Invalid CAPTCHA provider selected'; $this->redirect('/settings#security'); return; } // If CAPTCHA is enabled, validate keys if ($captchaProvider !== 'disabled') { if (empty($captchaSiteKey)) { $_SESSION['error'] = 'Site key is required when CAPTCHA is enabled'; $this->redirect('/settings#security'); return; } if (empty($captchaSecretKey)) { $_SESSION['error'] = 'Secret key is required when CAPTCHA is enabled'; $this->redirect('/settings#security'); return; } } // Validate v3 score threshold if ($captchaProvider === 'recaptcha_v3') { $threshold = floatval($recaptchaV3Threshold); if ($threshold < 0.0 || $threshold > 1.0) { $_SESSION['error'] = 'reCAPTCHA v3 score threshold must be between 0.0 and 1.0'; $this->redirect('/settings#security'); return; } } // Prepare settings array $captchaSettings = [ 'captcha_provider' => $captchaProvider, 'captcha_site_key' => $captchaSiteKey, 'recaptcha_v3_score_threshold' => $recaptchaV3Threshold ]; // Only update secret key if provided (to allow updating other settings without re-entering secret) if (!empty($captchaSecretKey)) { $captchaSettings['captcha_secret_key'] = $captchaSecretKey; } // Update CAPTCHA settings $this->settingModel->updateCaptchaSettings($captchaSettings); $_SESSION['success'] = 'CAPTCHA settings updated successfully'; $this->redirect('/settings#security'); } catch (\Exception $e) { $_SESSION['error'] = 'Failed to update CAPTCHA settings: ' . $e->getMessage(); $this->redirect('/settings#security'); } } public function testEmail() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings#email'); $testEmail = trim($_POST['test_email'] ?? ''); if (empty($testEmail) || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Please enter a valid email address'; $this->redirect('/settings#email'); return; } // Use EmailHelper to send test email $result = EmailHelper::sendTestEmail($testEmail); if ($result['success']) { $_SESSION['success'] = $result['message']; $this->logger->info('Test email sent successfully', [ 'email' => $testEmail ]); } else { // Log detailed error information for debugging $this->logger->error('Test email failed', [ 'email' => $testEmail, 'debug_info' => $result['debug_info'] ?? null, 'error' => $result['error'] ?? null ]); $_SESSION['error'] = $result['message']; // In development, show more detailed error if (($_ENV['APP_ENV'] ?? 'production') === 'development') { $_SESSION['error'] .= " (Debug: " . ($result['debug_info'] ?? $result['error']) . ")"; } } $this->redirect('/settings#email'); } public function updateTwoFactor() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } // CSRF Protection $this->verifyCsrf('/settings#security'); try { $twoFactorPolicy = trim($_POST['two_factor_policy'] ?? 'optional'); $rateLimitMinutes = (int)($_POST['two_factor_rate_limit_minutes'] ?? 15); $emailCodeExpiryMinutes = (int)($_POST['two_factor_email_code_expiry_minutes'] ?? 10); // Validate policy $validPolicies = ['disabled', 'optional', 'forced']; if (!in_array($twoFactorPolicy, $validPolicies)) { $_SESSION['error'] = 'Invalid 2FA policy selected'; $this->redirect('/settings#security'); return; } // Validate rate limit (1-60 minutes) if ($rateLimitMinutes < 1 || $rateLimitMinutes > 60) { $_SESSION['error'] = 'Rate limit must be between 1 and 60 minutes'; $this->redirect('/settings#security'); return; } // Validate email code expiry (1-30 minutes) if ($emailCodeExpiryMinutes < 1 || $emailCodeExpiryMinutes > 30) { $_SESSION['error'] = 'Email code expiry must be between 1 and 30 minutes'; $this->redirect('/settings#security'); return; } $twoFactorSettings = [ 'two_factor_policy' => $twoFactorPolicy, 'two_factor_rate_limit_minutes' => $rateLimitMinutes, 'two_factor_email_code_expiry_minutes' => $emailCodeExpiryMinutes ]; $this->settingModel->updateTwoFactorSettings($twoFactorSettings); $_SESSION['success'] = 'Two-Factor Authentication settings updated successfully'; $this->redirect('/settings#security'); } catch (\Exception $e) { $_SESSION['error'] = 'Failed to update 2FA settings: ' . $e->getMessage(); $this->redirect('/settings#security'); } } /** * Get isolation settings */ private function getIsolationSettings(): array { return [ 'user_isolation_mode' => $this->settingModel->getValue('user_isolation_mode', 'shared') ]; } /** * Toggle isolation mode */ public function toggleIsolationMode() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/settings'); return; } $this->verifyCsrf('/settings#isolation'); $newMode = $_POST['user_isolation_mode'] ?? 'shared'; try { if ($newMode === 'isolated') { // Check if we have any admin users $userModel = new \App\Models\User(); $adminUser = $userModel->getFirstAdminUser(); if (!$adminUser) { $_SESSION['error'] = 'No admin users found. Please create an admin user first.'; $this->redirect('/settings#isolation'); return; } // Run migration $migrationResult = $this->migrateToIsolatedMode(); if (!$migrationResult['success']) { $_SESSION['error'] = 'Migration failed: ' . $migrationResult['error']; $this->redirect('/settings#isolation'); return; } $_SESSION['success'] = "Isolation mode enabled. {$migrationResult['domains_assigned']} domains, {$migrationResult['groups_assigned']} groups, and {$migrationResult['tags_assigned']} tags assigned to admin."; } else { // Switching back to shared mode $this->settingModel->setValue('user_isolation_mode', 'shared'); $_SESSION['success'] = 'Switched to shared mode. All users can now see all domains and groups.'; } $this->redirect('/settings#isolation'); } catch (\Exception $e) { $_SESSION['error'] = 'Error updating isolation mode: ' . $e->getMessage(); $this->redirect('/settings#isolation'); } } /** * Migrate existing data to isolated mode */ private function migrateToIsolatedMode(): array { try { // Get the first admin user $userModel = new \App\Models\User(); $adminUser = $userModel->getFirstAdminUser(); if (!$adminUser) { throw new \Exception('No admin user found. Please create an admin user first.'); } $adminId = $adminUser['id']; // Assign all domains to admin $domainModel = new \App\Models\Domain(); $domainCount = $domainModel->assignUnassignedDomainsToUser($adminId); // Assign all groups to admin $groupModel = new \App\Models\NotificationGroup(); $groupCount = $groupModel->assignUnassignedGroupsToUser($adminId); // Assign all tags to admin $tagModel = new \App\Models\Tag(); $tagCount = $tagModel->assignUnassignedTagsToUser($adminId); // Set isolation mode $this->settingModel->setValue('user_isolation_mode', 'isolated'); return [ 'success' => true, 'admin_id' => $adminId, 'domains_assigned' => $domainCount, 'groups_assigned' => $groupCount, 'tags_assigned' => $tagCount ]; } catch (\Exception $e) { return [ 'success' => false, 'error' => $e->getMessage() ]; } } }