diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index ce740ce..56bab97 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -24,16 +24,83 @@ class DashboardController extends Controller { $stats = $this->domainModel->getStatistics(); $recentDomains = $this->domainModel->getRecent(5); // Get 5 most recent domains - $expiringThisMonth = $this->domainModel->getExpiringDomains(30); // Domains expiring within 30 days + + // Get expiring threshold from settings + $settingModel = new \App\Models\Setting(); + $notificationDays = $settingModel->getNotificationDays(); + $expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30; + + // Get expiring domains limited to top 5 + $allExpiringDomains = $this->domainModel->getExpiringDomains($expiringThreshold); + $expiringThisMonth = array_slice($allExpiringDomains, 0, 5); + $recentLogs = $this->logModel->getRecent(10); + $groups = $this->groupModel->all(); + + // Check system status + $systemStatus = $this->checkSystemStatus(); $this->view('dashboard/index', [ 'stats' => $stats, 'recentDomains' => $recentDomains, 'expiringThisMonth' => $expiringThisMonth, + 'expiringCount' => count($allExpiringDomains), 'recentLogs' => $recentLogs, + 'groups' => $groups, + 'systemStatus' => $systemStatus, 'title' => 'Dashboard' ]); } + + /** + * Check system status + */ + private function checkSystemStatus(): array + { + $status = [ + 'database' => ['status' => 'offline', 'color' => 'red'], + 'whois' => ['status' => 'offline', 'color' => 'red'], + 'notifications' => ['status' => 'disabled', 'color' => 'gray'] + ]; + + // Check database connection + try { + $pdo = \Core\Database::getConnection(); + $pdo->query("SELECT 1"); + $status['database'] = ['status' => 'online', 'color' => 'green']; + } catch (\Exception $e) { + $status['database'] = ['status' => 'offline', 'color' => 'red']; + } + + // Check WHOIS service (test with a known TLD) + 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) { + $status['whois'] = ['status' => 'active', 'color' => 'green']; + } else { + $status['whois'] = ['status' => 'no data', 'color' => 'yellow']; + } + } catch (\Exception $e) { + $status['whois'] = ['status' => 'error', 'color' => 'red']; + } + + // Check if any notification groups have active channels + try { + $channelModel = new \App\Models\NotificationChannel(); + $activeChannels = $channelModel->where('is_active', 1); + if (count($activeChannels) > 0) { + $status['notifications'] = ['status' => 'enabled', 'color' => 'green']; + } else { + $status['notifications'] = ['status' => 'no channels', 'color' => 'yellow']; + } + } catch (\Exception $e) { + $status['notifications'] = ['status' => 'error', 'color' => 'red']; + } + + return $status; + } } diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 51ca8c7..8d38a00 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -31,6 +31,11 @@ class DomainController extends Controller $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100 + // Get expiring threshold from settings + $settingModel = new \App\Models\Setting(); + $notificationDays = $settingModel->getNotificationDays(); + $expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30; + // Get all domains with groups $domains = $this->domainModel->getAllWithGroups(); @@ -43,12 +48,12 @@ class DomainController extends Controller } if (!empty($status)) { - $domains = array_filter($domains, function($domain) use ($status) { + $domains = array_filter($domains, function($domain) use ($status, $expiringThreshold) { if ($status === 'expiring_soon') { - // Check if domain expires within 30 days + // Check if domain expires within configured threshold if (!empty($domain['expiration_date'])) { $daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400); - return $daysLeft <= 30 && $daysLeft >= 0; + return $daysLeft <= $expiringThreshold && $daysLeft >= 0; } return false; } diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php new file mode 100644 index 0000000..5ad8ab6 --- /dev/null +++ b/app/Controllers/SettingsController.php @@ -0,0 +1,387 @@ +settingModel = new Setting(); + } + + public function index() + { + $settings = $this->settingModel->getAllAsKeyValue(); + $appSettings = $this->settingModel->getAppSettings(); + $emailSettings = $this->settingModel->getEmailSettings(); + + // 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] + ]; + + $this->view('settings/index', [ + 'settings' => $settings, + 'appSettings' => $appSettings, + 'emailSettings' => $emailSettings, + 'notificationPresets' => $notificationPresets, + 'checkIntervalPresets' => $checkIntervalPresets, + 'title' => 'Settings' + ]); + } + + public function update() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/settings'); + return; + } + + 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; + } + + // Save settings + $this->settingModel->setValue('notification_days_before', $notificationDays); + $this->settingModel->setValue('check_interval_hours', $checkInterval); + + $_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; + } + + // 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; + } + + try { + // Clear notification logs older than 30 days + $stmt = $this->settingModel->db->prepare( + "DELETE FROM notification_logs WHERE sent_at < DATE_SUB(NOW(), INTERVAL 30 DAY)" + ); + $stmt->execute(); + $deleted = $stmt->rowCount(); + + $_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; + } + + 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; + } + + $this->settingModel->updateAppSettings($appSettings); + $_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; + } + + try { + $emailSettings = [ + 'mail_host' => trim($_POST['mail_host'] ?? ''), + 'mail_port' => trim($_POST['mail_port'] ?? '2525'), + 'mail_username' => trim($_POST['mail_username'] ?? ''), + 'mail_password' => trim($_POST['mail_password'] ?? ''), + 'mail_encryption' => trim($_POST['mail_encryption'] ?? 'tls'), + '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 testEmail() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/settings'); + return; + } + + $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; + } + + try { + // Get current email settings + $emailSettings = $this->settingModel->getEmailSettings(); + $appSettings = $this->settingModel->getAppSettings(); + + // Create PHPMailer instance + $mail = new \PHPMailer\PHPMailer\PHPMailer(true); + + // Server settings + $mail->isSMTP(); + $mail->Host = $emailSettings['mail_host']; + $mail->SMTPAuth = !empty($emailSettings['mail_username']); + $mail->Username = $emailSettings['mail_username']; + $mail->Password = $emailSettings['mail_password']; + $mail->SMTPSecure = $emailSettings['mail_encryption']; + $mail->Port = $emailSettings['mail_port']; + + // Recipients + $mail->setFrom($emailSettings['mail_from_address'], $emailSettings['mail_from_name']); + $mail->addAddress($testEmail); + + // Content + $mail->isHTML(true); + $mail->Subject = 'Test Email from ' . $appSettings['app_name']; + + $appName = htmlspecialchars($appSettings['app_name']); + $appUrl = htmlspecialchars($appSettings['app_url']); + $currentTime = date('F j, Y g:i A'); + + $mail->Body = " + + + + + +
+
+

✅ Email Test Successful!

+
+
+
+ Success! Your email configuration is working correctly. +
+

This is a test email from {$appName}.

+

If you're seeing this message, it means your SMTP settings are configured properly and emails are being delivered successfully.

+ + + + + + + + + + + + + + + + + + + + + + +
SMTP Host:" . htmlspecialchars($emailSettings['mail_host']) . "
SMTP Port:" . htmlspecialchars($emailSettings['mail_port']) . "
Encryption:" . htmlspecialchars($emailSettings['mail_encryption'] ?: 'None') . "
From Address:" . htmlspecialchars($emailSettings['mail_from_address']) . "
Test Time:{$currentTime}
+
+ +
+ + + "; + + $mail->AltBody = "Email Test Successful!\n\n" . + "This is a test email from {$appName}.\n" . + "Your SMTP configuration is working correctly.\n\n" . + "SMTP Host: {$emailSettings['mail_host']}\n" . + "SMTP Port: {$emailSettings['mail_port']}\n" . + "From: {$emailSettings['mail_from_address']}\n" . + "Test Time: {$currentTime}"; + + $mail->send(); + + $_SESSION['success'] = "Test email sent successfully to {$testEmail}. Please check your inbox."; + $this->redirect('/settings#email'); + + } catch (\Exception $e) { + $_SESSION['error'] = "Failed to send test email: " . $e->getMessage(); + $this->redirect('/settings#email'); + } + } +} + diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..6f2f8c2 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,196 @@ +db->prepare("SELECT * FROM settings WHERE setting_key = ?"); + $stmt->execute([$key]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Get setting value by key + */ + public function getValue(string $key, $default = null) + { + $setting = $this->getByKey($key); + return $setting ? $setting['setting_value'] : $default; + } + + /** + * Set or update setting value + */ + public function setValue(string $key, $value): bool + { + $existing = $this->getByKey($key); + + if ($existing) { + $stmt = $this->db->prepare("UPDATE settings SET setting_value = ?, updated_at = NOW() WHERE setting_key = ?"); + return $stmt->execute([$value, $key]); + } else { + $stmt = $this->db->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?)"); + return $stmt->execute([$key, $value]); + } + } + + /** + * Get all settings as key-value pairs + */ + public function getAllAsKeyValue(): array + { + $settings = $this->all(); + $result = []; + + foreach ($settings as $setting) { + $result[$setting['setting_key']] = $setting['setting_value']; + } + + return $result; + } + + /** + * Get notification days as array + */ + public function getNotificationDays(): array + { + $value = $this->getValue('notification_days_before', '30,15,7,3,1'); + return array_map('intval', explode(',', $value)); + } + + /** + * Get check interval hours + */ + public function getCheckIntervalHours(): int + { + return (int)$this->getValue('check_interval_hours', 24); + } + + /** + * Update notification days + */ + public function updateNotificationDays(array $days): bool + { + $value = implode(',', array_map('intval', $days)); + return $this->setValue('notification_days_before', $value); + } + + /** + * Update check interval + */ + public function updateCheckInterval(int $hours): bool + { + return $this->setValue('check_interval_hours', $hours); + } + + /** + * Get last check run timestamp + */ + public function getLastCheckRun(): ?string + { + return $this->getValue('last_check_run'); + } + + /** + * Update last check run timestamp + */ + public function updateLastCheckRun(): bool + { + return $this->setValue('last_check_run', date('Y-m-d H:i:s')); + } + + /** + * Get application settings + */ + public function getAppSettings(): array + { + return [ + 'app_name' => $this->getValue('app_name', 'Domain Monitor'), + 'app_url' => $this->getValue('app_url', 'http://localhost:8000'), + 'app_timezone' => $this->getValue('app_timezone', 'UTC') + ]; + } + + /** + * Get email settings + */ + public function getEmailSettings(): array + { + $encryptedPassword = $this->getValue('mail_password', ''); + + // Decrypt password if it's encrypted + $decryptedPassword = ''; + if (!empty($encryptedPassword)) { + try { + $encryption = new \Core\Encryption(); + $decryptedPassword = $encryption->decrypt($encryptedPassword); + } catch (\Exception $e) { + // If decryption fails, it might be plaintext (migration scenario) + // Try to use as-is but log the issue + error_log("Failed to decrypt mail_password: " . $e->getMessage()); + $decryptedPassword = $encryptedPassword; + } + } + + return [ + 'mail_host' => $this->getValue('mail_host', 'smtp.mailtrap.io'), + 'mail_port' => $this->getValue('mail_port', '2525'), + 'mail_username' => $this->getValue('mail_username', ''), + 'mail_password' => $decryptedPassword, + 'mail_encryption' => $this->getValue('mail_encryption', 'tls'), + 'mail_from_address' => $this->getValue('mail_from_address', 'noreply@domainmonitor.com'), + 'mail_from_name' => $this->getValue('mail_from_name', 'Domain Monitor') + ]; + } + + /** + * Update application settings + */ + public function updateAppSettings(array $settings): bool + { + $result = true; + foreach ($settings as $key => $value) { + if (!$this->setValue($key, $value)) { + $result = false; + } + } + return $result; + } + + /** + * Update email settings + */ + public function updateEmailSettings(array $settings): bool + { + $result = true; + + foreach ($settings as $key => $value) { + // Encrypt mail_password before storing + if ($key === 'mail_password' && !empty($value)) { + try { + $encryption = new \Core\Encryption(); + $value = $encryption->encrypt($value); + } catch (\Exception $e) { + error_log("Failed to encrypt mail_password: " . $e->getMessage()); + return false; + } + } + + if (!$this->setValue($key, $value)) { + $result = false; + } + } + return $result; + } +} + diff --git a/app/Services/Channels/EmailChannel.php b/app/Services/Channels/EmailChannel.php index cb7793a..f784a1d 100644 --- a/app/Services/Channels/EmailChannel.php +++ b/app/Services/Channels/EmailChannel.php @@ -4,6 +4,7 @@ namespace App\Services\Channels; use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\Exception; +use App\Models\Setting; class EmailChannel implements NotificationChannelInterface { @@ -12,23 +13,28 @@ class EmailChannel implements NotificationChannelInterface $mail = new PHPMailer(true); try { + // Get email settings from database + $settingModel = new Setting(); + $emailSettings = $settingModel->getEmailSettings(); + $appSettings = $settingModel->getAppSettings(); + // Server settings $mail->isSMTP(); - $mail->Host = $_ENV['MAIL_HOST']; - $mail->SMTPAuth = true; - $mail->Username = $_ENV['MAIL_USERNAME']; - $mail->Password = $_ENV['MAIL_PASSWORD']; - $mail->SMTPSecure = $_ENV['MAIL_ENCRYPTION']; - $mail->Port = $_ENV['MAIL_PORT']; + $mail->Host = $emailSettings['mail_host']; + $mail->SMTPAuth = !empty($emailSettings['mail_username']); + $mail->Username = $emailSettings['mail_username']; + $mail->Password = $emailSettings['mail_password']; + $mail->SMTPSecure = $emailSettings['mail_encryption']; + $mail->Port = $emailSettings['mail_port']; // Recipients - $mail->setFrom($_ENV['MAIL_FROM_ADDRESS'], $_ENV['MAIL_FROM_NAME']); + $mail->setFrom($emailSettings['mail_from_address'], $emailSettings['mail_from_name']); $mail->addAddress($config['email']); // Content $mail->isHTML(true); $mail->Subject = $this->getSubject($data); - $mail->Body = $this->formatHtmlBody($message, $data); + $mail->Body = $this->formatHtmlBody($message, $data, $appSettings); $mail->AltBody = strip_tags($message); $mail->send(); @@ -55,9 +61,18 @@ class EmailChannel implements NotificationChannelInterface return "Domain Monitor Alert"; } - private function formatHtmlBody(string $message, array $data): string + private function formatHtmlBody(string $message, array $data, array $appSettings): string { $messageHtml = nl2br(htmlspecialchars($message)); + $appName = htmlspecialchars($appSettings['app_name']); + $appUrl = htmlspecialchars($appSettings['app_url']); + + // Build domain link if domain ID is available + $domainLink = ''; + if (isset($data['domain_id'])) { + $domainUrl = rtrim($appUrl, '/') . '/domains/' . $data['domain_id']; + $domainLink = "

View Domain Details

"; + } return " @@ -74,13 +89,15 @@ class EmailChannel implements NotificationChannelInterface
-

🔔 Domain Monitor Alert

+

🔔 {$appName} Alert

$messageHtml

+ $domainLink
diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index d829fc7..84bcb9a 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -93,6 +93,7 @@ class NotificationService $message, [ 'domain' => $domain['domain_name'], + 'domain_id' => $domain['id'], 'days_left' => $daysLeft, 'expiration_date' => $domain['expiration_date'], 'registrar' => $domain['registrar'] diff --git a/app/Services/WhoisService.php b/app/Services/WhoisService.php index 63b7675..a3c4a09 100644 --- a/app/Services/WhoisService.php +++ b/app/Services/WhoisService.php @@ -679,11 +679,19 @@ class WhoisService { // Check if domain is available (not registered) foreach ($statusArray as $status) { - if (stripos($status, 'AVAILABLE') !== false || stripos($status, 'FREE') !== false) { + if (stripos($status, 'AVAILABLE') !== false || + stripos($status, 'FREE') !== false || + stripos($status, 'NO MATCH') !== false || + stripos($status, 'NOT FOUND') !== false) { return 'available'; } } + // Also check if expiration date is null and no status indicates it's registered + if ($expirationDate === null && empty($statusArray)) { + return 'available'; + } + $days = $this->daysUntilExpiration($expirationDate); if ($days === null) { diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index e8932a4..abce125 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -39,7 +39,8 @@ ob_start();

Expiring Soon

-

+

+

within days

@@ -64,25 +65,26 @@ ob_start();
-
-
-

- - Recent Domains -

-
-
+
+
+
+

+ + Recent Domains +

+
+
-
+
-
- +
+
-

-
+

+
@@ -102,10 +104,19 @@ ob_start();
'bg-green-100 text-green-700', + 'expiring_soon' => 'bg-orange-100 text-orange-700', + 'expired' => 'bg-red-100 text-red-700', + 'error' => 'bg-red-100 text-red-700', + 'available' => 'bg-blue-100 text-blue-700' + ]; + $statusClass = $statusClasses[$status] ?? 'bg-gray-100 text-gray-700'; + $statusLabel = $status === 'expiring_soon' ? 'Expiring Soon' : ($status === 'available' ? 'Available' : ucfirst($status)); ?> - + @@ -114,22 +125,23 @@ ob_start();
-
@@ -174,54 +186,85 @@ ob_start();
+ 'text-green-600', + 'yellow' => 'text-yellow-600', + 'red' => 'text-red-600', + 'gray' => 'text-gray-600' + ]; + ?>
Database - + - Online +
- WHOIS Service - + TLD Registry + - Active +
Notifications - + - Enabled +
- - -
-
-

- - Expiring This Month -

-
-
- -
-
-

-

-
- - + + + +
+ + +
+
+

+

+ + + days + +

+
+ + + +
+ +
+ +
+ +

No domains expiring soon

+

within days

+
+
-
diff --git a/app/Views/layout/base.php b/app/Views/layout/base.php index 95e5113..8d864d7 100644 --- a/app/Views/layout/base.php +++ b/app/Views/layout/base.php @@ -19,24 +19,47 @@ if (!isset($globalStats)) { $activeResult = $activeStmt->fetch(\PDO::FETCH_ASSOC); $active = $activeResult['count'] ?? 0; - // Get expiring soon (within 30 days) - $expiringSoonStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE expiration_date IS NOT NULL AND expiration_date <= DATE_ADD(NOW(), INTERVAL 30 DAY) AND expiration_date >= NOW()"); + // Get expiring soon - use the first notification threshold from settings + $settingModel = new \App\Models\Setting(); + $notificationDays = $settingModel->getNotificationDays(); + $expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30; // Use the largest notification day + + $expiringSoonStmt = $pdo->prepare("SELECT COUNT(*) as count FROM domains WHERE is_active = 1 AND expiration_date IS NOT NULL AND expiration_date <= DATE_ADD(NOW(), INTERVAL ? DAY) AND expiration_date >= NOW()"); + $expiringSoonStmt->execute([$expiringThreshold]); $expiringSoonResult = $expiringSoonStmt->fetch(\PDO::FETCH_ASSOC); $expiringSoon = $expiringSoonResult['count'] ?? 0; $globalStats = [ 'total' => $total, 'active' => $active, - 'expiring_soon' => $expiringSoon + 'expiring_soon' => $expiringSoon, + 'expiring_threshold' => $expiringThreshold ]; } catch (\Exception $e) { $globalStats = [ 'total' => 0, 'active' => 0, - 'expiring_soon' => 0 + 'expiring_soon' => 0, + 'expiring_threshold' => 30 ]; } } + +// Get application settings from database +if (!isset($appName)) { + try { + $settingModel = new \App\Models\Setting(); + $appSettings = $settingModel->getAppSettings(); + $appName = $appSettings['app_name']; + $appTimezone = $appSettings['app_timezone']; + + // Set PHP timezone + date_default_timezone_set($appTimezone); + } catch (\Exception $e) { + $appName = 'Domain Monitor'; + date_default_timezone_set('UTC'); + } +} ?> @@ -49,7 +72,7 @@ if (!isset($globalStats)) { - <?= $title ?? 'Domain Monitor' ?> - <?= $_ENV['APP_NAME'] ?? 'Domain Monitor' ?> + <?= $title ?? 'Domain Monitor' ?> - <?= $appName ?> diff --git a/app/Views/layout/sidebar.php b/app/Views/layout/sidebar.php index 38a40e1..3c70c74 100644 --- a/app/Views/layout/sidebar.php +++ b/app/Views/layout/sidebar.php @@ -8,7 +8,7 @@
-

+

@@ -46,6 +46,17 @@
+ + +
+

System

+ +
@@ -70,7 +81,7 @@
- Expiring + Expiring
diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php new file mode 100644 index 0000000..1d83b62 --- /dev/null +++ b/app/Views/settings/index.php @@ -0,0 +1,584 @@ + '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' +]; + +// Determine which preset is selected +$selectedPreset = 'custom'; +foreach ($notificationPresets as $key => $preset) { + if ($preset['value'] === $currentNotificationDays) { + $selectedPreset = $key; + break; + } +} +?> + + +
+
+ +
+
+ + +
+
+
+

Application Settings

+

Configure basic application information

+
+ +
+
+
+ + +

Name displayed in the interface

+
+ +
+ + +

Base URL for the application (used in emails and links)

+
+ +
+ + +

Application timezone for dates and times

+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/core/Encryption.php b/core/Encryption.php new file mode 100644 index 0000000..6dc67b1 --- /dev/null +++ b/core/Encryption.php @@ -0,0 +1,106 @@ +key = base64_decode($key); + + if (strlen($this->key) !== 32) { + throw new \Exception('APP_ENCRYPTION_KEY must be 32 bytes (base64 encoded). Generate one using: php -r "echo base64_encode(random_bytes(32));"'); + } + } + + /** + * Encrypt a value + */ + public function encrypt(string $value): string + { + if (empty($value)) { + return ''; + } + + // Generate a random IV (Initialization Vector) + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($this->cipher)); + + // Encrypt the value + $encrypted = openssl_encrypt($value, $this->cipher, $this->key, 0, $iv); + + if ($encrypted === false) { + throw new \Exception('Encryption failed'); + } + + // Combine IV and encrypted data, then base64 encode + $result = base64_encode($iv . $encrypted); + + return $result; + } + + /** + * Decrypt a value + */ + public function decrypt(string $encrypted): string + { + if (empty($encrypted)) { + return ''; + } + + // Decode from base64 + $data = base64_decode($encrypted); + + if ($data === false) { + throw new \Exception('Invalid encrypted data'); + } + + // Extract IV and encrypted data + $ivLength = openssl_cipher_iv_length($this->cipher); + $iv = substr($data, 0, $ivLength); + $encryptedData = substr($data, $ivLength); + + // Decrypt the value + $decrypted = openssl_decrypt($encryptedData, $this->cipher, $this->key, 0, $iv); + + if ($decrypted === false) { + throw new \Exception('Decryption failed'); + } + + return $decrypted; + } + + /** + * Generate a new encryption key (base64 encoded) + * This should be run once and the result stored in .env + */ + public static function generateKey(): string + { + return base64_encode(random_bytes(32)); + } + + /** + * Check if a value is encrypted (basic heuristic) + */ + public function isEncrypted(string $value): bool + { + if (empty($value)) { + return false; + } + + // Encrypted values are base64 encoded + // They should be longer than typical plaintext passwords + // and contain only base64 characters + return preg_match('/^[A-Za-z0-9+\/]+=*$/', $value) && strlen($value) > 40; + } +} + diff --git a/cron/check_domains.php b/cron/check_domains.php index feb1150..adf5b02 100644 --- a/cron/check_domains.php +++ b/cron/check_domains.php @@ -17,6 +17,7 @@ use Dotenv\Dotenv; use App\Models\Domain; use App\Models\NotificationChannel; use App\Models\NotificationLog; +use App\Models\Setting; use App\Services\WhoisService; use App\Services\NotificationService; use Core\Database; @@ -32,9 +33,18 @@ new Database(); $domainModel = new Domain(); $channelModel = new NotificationChannel(); $logModel = new NotificationLog(); +$settingModel = new Setting(); $whoisService = new WhoisService(); $notificationService = new NotificationService(); +// Set timezone from settings +try { + $appSettings = $settingModel->getAppSettings(); + date_default_timezone_set($appSettings['app_timezone']); +} catch (\Exception $e) { + date_default_timezone_set('UTC'); +} + // Log file $logFile = __DIR__ . '/../logs/cron.log'; @@ -45,11 +55,28 @@ function logMessage(string $message) { echo "[$timestamp] $message\n"; } +function formatElapsedTime(float $seconds): string { + if ($seconds < 60) { + return sprintf("%.2f seconds", $seconds); + } elseif ($seconds < 3600) { + $minutes = floor($seconds / 60); + $remainingSeconds = $seconds - ($minutes * 60); + return sprintf("%d minute%s %.2f seconds", $minutes, $minutes != 1 ? 's' : '', $remainingSeconds); + } else { + $hours = floor($seconds / 3600); + $remainingMinutes = floor(($seconds - ($hours * 3600)) / 60); + $remainingSeconds = $seconds - ($hours * 3600) - ($remainingMinutes * 60); + return sprintf("%d hour%s %d minute%s %.2f seconds", $hours, $hours != 1 ? 's' : '', $remainingMinutes, $remainingMinutes != 1 ? 's' : '', $remainingSeconds); + } +} + +// Record start time +$startTime = microtime(true); + logMessage("=== Starting domain check cron job ==="); -// Get notification days from settings -$notificationDays = explode(',', $_ENV['NOTIFICATION_DAYS_BEFORE'] ?? '30,15,7,3,1'); -$notificationDays = array_map('intval', $notificationDays); +// Get notification days from database settings +$notificationDays = $settingModel->getNotificationDays(); logMessage("Notification thresholds (days): " . implode(', ', $notificationDays)); @@ -180,12 +207,21 @@ foreach ($domains as $domain) { } } +// Update last check run timestamp +$settingModel->updateLastCheckRun(); + +// Calculate elapsed time +$endTime = microtime(true); +$elapsedTime = $endTime - $startTime; +$formattedTime = formatElapsedTime($elapsedTime); + // Summary logMessage("\n=== Cron job completed ==="); logMessage("Domains checked: {$stats['checked']}"); logMessage("Domains updated: {$stats['updated']}"); logMessage("Notifications sent: {$stats['notifications_sent']}"); logMessage("Errors: {$stats['errors']}"); +logMessage("Execution time: $formattedTime"); logMessage("==========================\n"); exit(0); diff --git a/database/migrate.php b/database/migrate.php index d62a255..8fee30c 100644 --- a/database/migrate.php +++ b/database/migrate.php @@ -10,6 +10,16 @@ $dotenv = Dotenv::createImmutable(__DIR__ . '/..'); $dotenv->load(); try { + // Check if encryption key is set + if (empty($_ENV['APP_ENCRYPTION_KEY'])) { + echo "⚠️ WARNING: APP_ENCRYPTION_KEY is not set in .env\n"; + echo " This key is required to encrypt sensitive data (like SMTP passwords).\n\n"; + echo " Generate one using:\n"; + echo " php scripts/generate-encryption-key.php\n\n"; + echo " Then add it to your .env file and run migrations again.\n\n"; + exit(1); + } + $host = $_ENV['DB_HOST']; $port = $_ENV['DB_PORT']; $database = $_ENV['DB_DATABASE']; @@ -36,6 +46,7 @@ try { __DIR__ . '/migrations/004_create_tld_registry_table.sql', __DIR__ . '/migrations/005_update_tld_import_logs.sql', __DIR__ . '/migrations/006_add_complete_workflow_import_type.sql', + __DIR__ . '/migrations/007_add_app_and_email_settings.sql', ]; foreach ($migrationFiles as $migrationFile) { diff --git a/database/migrations/001_create_tables.sql b/database/migrations/001_create_tables.sql index b875880..c91b44a 100644 --- a/database/migrations/001_create_tables.sql +++ b/database/migrations/001_create_tables.sql @@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS domains ( registrar VARCHAR(255), expiration_date DATE, last_checked TIMESTAMP NULL, - status ENUM('active', 'expiring_soon', 'expired', 'error') DEFAULT 'active', + status ENUM('active', 'expiring_soon', 'expired', 'error', 'available') DEFAULT 'active', whois_data JSON, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/database/migrations/007_add_app_and_email_settings.sql b/database/migrations/007_add_app_and_email_settings.sql new file mode 100644 index 0000000..ce8c983 --- /dev/null +++ b/database/migrations/007_add_app_and_email_settings.sql @@ -0,0 +1,17 @@ +-- Add application and email settings to database +INSERT INTO settings (setting_key, setting_value) VALUES +-- Application Settings +('app_name', 'Domain Monitor'), +('app_url', 'http://localhost:8000'), +('app_timezone', 'UTC'), + +-- Email Settings +('mail_host', 'smtp.mailtrap.io'), +('mail_port', '2525'), +('mail_username', ''), +('mail_password', ''), +('mail_encryption', 'tls'), +('mail_from_address', 'noreply@domainmonitor.com'), +('mail_from_name', 'Domain Monitor') +ON DUPLICATE KEY UPDATE setting_key=setting_key; + diff --git a/env.example.txt b/env.example.txt index cc0e852..77c9b25 100644 --- a/env.example.txt +++ b/env.example.txt @@ -1,8 +1,10 @@ # Application -APP_NAME="Domain Monitor" APP_ENV=development -APP_URL=http://localhost:8000 -APP_TIMEZONE=UTC + +# Security - Generate encryption key using: php -r "echo base64_encode(random_bytes(32));" +# This key is used to encrypt sensitive data in the database (like SMTP passwords) +# IMPORTANT: Never share this key or commit it to version control! +APP_ENCRYPTION_KEY= # Database DB_HOST=localhost @@ -15,18 +17,4 @@ DB_PASSWORD= SESSION_LIFETIME=1440 SESSION_COOKIE_HTTPONLY=1 SESSION_COOKIE_SECURE=0 -SESSION_COOKIE_SAMESITE=Strict - -# Email Configuration -MAIL_DRIVER=smtp -MAIL_HOST=smtp.mailtrap.io -MAIL_PORT=2525 -MAIL_USERNAME= -MAIL_PASSWORD= -MAIL_ENCRYPTION=tls -MAIL_FROM_ADDRESS=noreply@domainmonitor.com -MAIL_FROM_NAME="Domain Monitor" - -# Domain Check Settings -CHECK_INTERVAL_HOURS=24 -NOTIFICATION_DAYS_BEFORE=60,30,21,14,7,5,3,2,1 \ No newline at end of file +SESSION_COOKIE_SAMESITE=Strict \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 60cbd3e..a4bdf85 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Controllers\AuthController; use App\Controllers\DebugController; use App\Controllers\SearchController; use App\Controllers\TldRegistryController; +use App\Controllers\SettingsController; $router = Application::$router; @@ -76,3 +77,12 @@ $router->get('/tld-registry/{id}/refresh', [TldRegistryController::class, 'refre $router->get('/tld-registry/import-logs', [TldRegistryController::class, 'importLogs']); $router->get('/api/tld-info', [TldRegistryController::class, 'apiGetTldInfo']); +// Settings +$router->get('/settings', [SettingsController::class, 'index']); +$router->post('/settings/update', [SettingsController::class, 'update']); +$router->post('/settings/update-app', [SettingsController::class, 'updateApp']); +$router->post('/settings/update-email', [SettingsController::class, 'updateEmail']); +$router->post('/settings/test-email', [SettingsController::class, 'testEmail']); +$router->post('/settings/test-cron', [SettingsController::class, 'testCron']); +$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']); + diff --git a/scripts/generate-encryption-key.php b/scripts/generate-encryption-key.php new file mode 100644 index 0000000..cffa8e0 --- /dev/null +++ b/scripts/generate-encryption-key.php @@ -0,0 +1,36 @@ +#!/usr/bin/env php +