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 = "
+
+
+
+
+
+
+
+
+
+ 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
$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
-
= $stats['expiring_soon'] ?? 0 ?>
+
= $globalStats['expiring_soon'] ?? 0 ?>
+
within = $globalStats['expiring_threshold'] ?? 30 ?> days
@@ -64,25 +65,26 @@ ob_start();
-
-
-
-
- Recent Domains
-
-
-
+
+
+
+
+
+ Recent Domains
+
+
+
-
+
-
-
+
+
-
= htmlspecialchars($domain['domain_name']) ?>
-
+
= htmlspecialchars($domain['domain_name']) ?>
+
@@ -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));
?>
- = ucfirst($domain['status']) ?>
+ = $statusLabel ?>
@@ -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
+ = ucfirst($systemStatus['database']['status']) ?>
- WHOIS Service
-
+ TLD Registry
+
- Active
+ = ucfirst($systemStatus['whois']['status']) ?>
Notifications
-
+
- Enabled
+ = ucfirst($systemStatus['notifications']['status']) ?>
-
-
-
-
-
-
- Expiring This Month
-
-
-
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 @@
-
= $_ENV['APP_NAME'] ?? 'Domain Monitor' ?>
+
= $appName ?? 'Domain Monitor' ?>
@@ -46,6 +46,17 @@
+
+
+
@@ -70,7 +81,7 @@
-
Expiring
+
Expiring
= $globalStats['expiring_soon'] ?? 0 ?>
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
+
+
+
+
+
+
+
+
+
+
+
Email Settings
+
Configure SMTP server for sending notifications
+
+
+
+
+
+
+
+
+
+
Test Email Configuration
+
+ Send a test email to verify your SMTP settings are configured correctly.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Monitoring Settings
+
Configure notification schedules and check intervals
+
+
+
+
+
+
+
+
+
+
+
System Information
+
Cron job configuration and log file locations
+
+
+
+
+
+
+
+ Cron Job Command
+
+
+ php cron/check_domains.php
+
+
+
+
+
+
+
+ Recommended Crontab Entry
+
+
+ 0 */= $currentCheckInterval ?> * * * php /path/to/cron/check_domains.php
+
+
Update the path to match your server installation
+
+
+
+
+
+
+ Log Files
+
+
+
+
+
Cron Log
+
Domain check execution logs
+
+
logs/cron.log
+
+
+
+
TLD Import Log
+
TLD registry import logs
+
+
logs/tld_import_*.log
+
+
+
+
+
+
+
+
+
+
+
+
Maintenance Tools
+
Database cleanup and system maintenance
+
+
+
+
+
+
+
+ Clear Old Notification Logs
+
+
+
+
+
+
+
Warning
+
+ This will permanently delete all notification logs older than 30 days. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Database Optimization
+
+ Regular maintenance keeps your system running smoothly. Consider clearing old logs monthly.
+
+
+
+
+
+
+
+
+
+
+
+
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
+