diff --git a/CHANGELOG.md b/CHANGELOG.md index d49191a..313b1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to Domain Monitor will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.1] - 2025-11-18 + +### Added +- **Pushover Notification Channel** - Send domain expiration alerts via Pushover (iOS, Android, Desktop) + - Priority-based notifications (Emergency, High, Normal, Low) based on days until expiration + - Emergency alerts (expired or expiring in ≤1 day) with auto-retry every 5 minutes for 1 hour + - 23 custom notification sounds to choose from + - Device targeting - send to specific devices or all devices + - Rich notifications with title, message, and clickable URL to domain details + - Optional custom sound and device configuration + - Database migration `022_add_pushover_channel_type.sql` to add Pushover support + +### Fixed +- **Security: PHP 8.x URI Injection Vulnerability** - Fixed deprecated `strpos()` null parameter warning + - Added early request validation in `public/index.php` to block malformed URIs + - Enhanced `core/Auth.php` to handle null values from `parse_url()` gracefully + - Malformed requests are now logged and return 400 Bad Request + - Prevents attackers from causing PHP warnings via malformed URI probes +- **PHP 8.x Compatibility: strtotime() Null Parameter** - Fixed deprecated warnings for null expiration dates + - Added null checks before calling `strtotime()` in all domain view templates + - Displays "Unknown" for domains without expiration dates (e.g., .nl domains) + - Updated 9 view files: groups/edit, domains/index, domains/view, domains/edit, dashboard/index, tags/view, search/results + - Also fixed `NotificationService::formatExpirationMessage()` to handle null dates +- **Domain Status Detection for .nl Domains** - Fixed incorrect "available" status for registered .nl domains + - `.nl` WHOIS/RDAP doesn't always provide expiration dates or explicit status flags + - Improved `WhoisService::getDomainStatus()` to detect registered domains via nameservers and valid registrar + - Cron job now preserves existing expiration dates when WHOIS doesn't return one + - Prevents false positives for domain availability +- **Domain Status Detection for .eu Domains** - Fixed incorrect status and registrar parsing for .eu domains + - Added specific `.eu` registrar format parsing (`Name: Registrar Name`) + - Fixed RDAP vCard parsing to strip "Name:" prefix from registrar field + - Fixed WHOIS parsing to handle "Name: Company" format in registrar sections + - Enhanced status detection logic to recognize registered domains without explicit status flags + - Consistent behavior between manual refresh and automated cron checks +- **Logging Consistency** - Replaced all remaining `error_log()` calls with custom Logger service + - Updated `WhoisService.php`, `NotificationService.php`, `AuthController.php`, `UserController.php` + - Centralized structured logging throughout the application + - Better debugging and audit trail capabilities + +### Changed +- **Status Detection** - Unified `DomainHelper::determineStatus()` to use `WhoisService::getDomainStatus()` for consistency +- **Documentation** - Updated README.md to reflect all available notification channels including Pushover + ## [1.1.0] - 2025-10-09 ### Added diff --git a/README.md b/README.md index e69ca77..62639ac 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) -A modern PHP MVC application for monitoring domain expiration dates and sending notifications through multiple channels (Email, Telegram, Discord, Slack). Never lose a domain again with automated monitoring and timely alerts. +A modern PHP MVC application for monitoring domain expiration dates and sending notifications through multiple channels (Email, Telegram, Discord, Slack, Mattermost, Pushover, Webhook). Never lose a domain again with automated monitoring and timely alerts. ## ✨ Features @@ -14,7 +14,7 @@ A modern PHP MVC application for monitoring domain expiration dates and sending - 📋 **Domain Management** - Add, edit, and monitor unlimited domains - 🔍 **Smart WHOIS/RDAP Lookup** - Automatically fetches expiration dates and registrar information - 🗂️ **TLD Registry System** - Built-in support for 1,400+ TLDs with IANA integration -- 🔔 **Multi-Channel Notifications** - Email, Telegram, Discord, and Slack support +- 🔔 **Multi-Channel Notifications** - Email, Telegram, Discord, Slack, Mattermost, Pushover, and Webhook support - 👥 **Notification Groups** - Organize channels and assign domains flexibly - ⚡ **Real-time Dashboard** - Overview of all domains and their status - 📊 **Notification Logs** - Complete history of all sent notifications diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index ee77640..3e743af 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -94,7 +94,11 @@ class AuthController extends Controller } if (!$user) { - error_log("Login failed: User '$username' not found or not active"); + $logger = new \App\Services\Logger(); + $logger->warning("Login failed - User not found or not active", [ + 'username' => $username, + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ]); $_SESSION['error'] = 'Invalid username or password'; $this->redirect('/login'); return; @@ -102,14 +106,22 @@ class AuthController extends Controller // Verify password if (!$this->userModel->verifyPassword($password, $user['password'])) { - error_log("Login failed: Password verification failed for user '$username'"); - error_log("Stored hash: {$user['password']}"); + $logger = new \App\Services\Logger(); + $logger->warning("Login failed - Password verification failed", [ + 'username' => $username, + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ]); $_SESSION['error'] = 'Invalid username or password'; $this->redirect('/login'); return; } - error_log("Login successful for user '$username'"); + $logger = new \App\Services\Logger(); + $logger->info("Login successful", [ + 'username' => $username, + 'user_id' => $user['id'], + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ]); // Check if email verification is required $requireVerification = $this->settingModel->getValue('require_email_verification'); @@ -310,7 +322,11 @@ class AuthController extends Controller $notificationService->notifyWelcome($userId, $username); } catch (\Exception $e) { // Don't fail registration if notification fails - error_log("Failed to create welcome notification: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error("Failed to create welcome notification", [ + 'user_id' => $userId, + 'error' => $e->getMessage() + ]); } // Check if email verification is required @@ -684,7 +700,11 @@ class AuthController extends Controller } catch (\Exception $e) { // Silently fail - remember me is not critical - error_log("Failed to create remember token: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error("Failed to create remember token", [ + 'user_id' => $userId, + 'error' => $e->getMessage() + ]); } } diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 7706fa8..8c8ccbe 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -212,7 +212,7 @@ class DomainController extends Controller } // Create domain - $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); + $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? [], $whoisData); // Warn if domain is available (not registered) if ($status === 'available') { @@ -462,7 +462,7 @@ class DomainController extends Controller // Use WHOIS expiration date if available, otherwise preserve manual expiration date $expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date']; - $status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? []); + $status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData); $this->domainModel->update($id, [ 'registrar' => $whoisData['registrar'], @@ -673,7 +673,7 @@ class DomainController extends Controller continue; } - $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); + $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? [], $whoisData); // Track available domains if ($status === 'available') { @@ -792,7 +792,7 @@ class DomainController extends Controller // Use WHOIS expiration date if available, otherwise preserve manual expiration date $expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date']; - $status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? []); + $status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData); $this->domainModel->update($id, [ 'registrar' => $whoisData['registrar'], diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index a66e779..dd87d9d 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -52,6 +52,8 @@ class InstallerController extends Controller '019_add_webhook_channel_type.sql', '020_create_tags_system.sql', '021_add_avatar_field.sql', + '022_add_pushover_channel_type.sql', + '023_update_app_version_to_1.1.1.sql', ]; try { @@ -175,7 +177,7 @@ class InstallerController extends Controller $stmt->execute([$migration]); } - // Return only new migrations for v1.1.0 + // Return only new migrations for v1.1.x return [ '009_add_authentication_features.sql', '010_add_app_version_setting.sql', @@ -189,7 +191,9 @@ class InstallerController extends Controller '018_add_user_isolation.sql', '019_add_webhook_channel_type.sql', '020_create_tags_system.sql', - '021_add_avatar_field.sql' + '021_add_avatar_field.sql', + '022_add_pushover_channel_type.sql', + '023_update_app_version_to_1.1.1.sql', ]; } @@ -373,6 +377,8 @@ class InstallerController extends Controller '019_add_webhook_channel_type.sql', '020_create_tags_system.sql', '021_add_avatar_field.sql', + '022_add_pushover_channel_type.sql', + '023_update_app_version_to_1.1.1.sql', ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); @@ -591,10 +597,12 @@ class InstallerController extends Controller // Determine from/to versions based on migrations $fromVersion = '1.0.0'; - $toVersion = '1.1.0'; + $toVersion = '1.1.1'; // Detect version based on which migrations were run - if (in_array('011_create_sessions_table.sql', $executed) || + if (in_array('022_add_pushover_channel_type.sql', $executed)) { + $toVersion = '1.1.1'; + } elseif (in_array('011_create_sessions_table.sql', $executed) || in_array('012_link_remember_tokens_to_sessions.sql', $executed) || in_array('013_create_user_notifications_table.sql', $executed)) { $toVersion = '1.1.0'; diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index a171174..99b131a 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -305,6 +305,9 @@ class NotificationGroupController extends Controller case 'webhook': $missingField = 'webhook URL'; break; + case 'pushover': + $missingField = empty($_POST['pushover_api_token']) ? 'API token' : 'user key'; + break; } $_SESSION['error'] = "Invalid channel configuration: Missing {$missingField}"; @@ -545,6 +548,39 @@ class NotificationGroupController extends Controller } return ['webhook_url' => $webhookUrl]; + case 'pushover': + $apiToken = trim($data['pushover_api_token'] ?? ''); + $userKey = trim($data['pushover_user_key'] ?? ''); + + // Both API token and user key are required + if (empty($apiToken) || empty($userKey)) { + return null; + } + + // Basic validation for Pushover token format (30 characters, alphanumeric) + if (!preg_match('/^[a-zA-Z0-9]{30}$/', $apiToken) || !preg_match('/^[a-zA-Z0-9]{30}$/', $userKey)) { + return null; + } + + $config = [ + 'api_token' => $apiToken, + 'user_key' => $userKey + ]; + + // Optional: Device name + $device = trim($data['pushover_device'] ?? ''); + if (!empty($device)) { + $config['device'] = $device; + } + + // Optional: Sound + $sound = trim($data['pushover_sound'] ?? ''); + if (!empty($sound)) { + $config['sound'] = $sound; + } + + return $config; + case 'webhook': $webhookUrl = trim($data['webhook_url'] ?? ''); if (empty($webhookUrl) || !filter_var($webhookUrl, FILTER_VALIDATE_URL)) { diff --git a/app/Controllers/ProfileController.php b/app/Controllers/ProfileController.php index a2b853f..bf379e8 100644 --- a/app/Controllers/ProfileController.php +++ b/app/Controllers/ProfileController.php @@ -45,7 +45,11 @@ class ProfileController extends Controller $this->sessionModel->cleanOldSessions(); } catch (\Exception $e) { // Silent fail - don't break the page - error_log("Session cleanup failed: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error('Session cleanup failed', [ + 'user_id' => \Core\Auth::id(), + 'error' => $e->getMessage() + ]); } // Get all active sessions diff --git a/app/Controllers/UserController.php b/app/Controllers/UserController.php index 3fa3bf8..862dd0b 100644 --- a/app/Controllers/UserController.php +++ b/app/Controllers/UserController.php @@ -179,7 +179,11 @@ class UserController extends Controller $notificationService->notifyWelcome($userId, $username); } catch (\Exception $e) { // Don't fail user creation if notification fails - error_log("Failed to create welcome notification: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error("Failed to create welcome notification", [ + 'user_id' => $userId, + 'error' => $e->getMessage() + ]); } $_SESSION['success'] = 'User created successfully'; diff --git a/app/Helpers/DomainHelper.php b/app/Helpers/DomainHelper.php index 979d583..bb3b188 100644 --- a/app/Helpers/DomainHelper.php +++ b/app/Helpers/DomainHelper.php @@ -64,6 +64,7 @@ class DomainHelper /** * Determine domain status from WHOIS data + * Uses WhoisService for consistent status detection across the application */ private static function determineStatus(array $domain): string { @@ -76,23 +77,14 @@ class DomainHelper // Parse WHOIS data $whoisData = json_decode($domain['whois_data'] ?? '{}', true); + + // Use WhoisService for consistent status detection + // This ensures .eu/.nl domains and others are handled correctly + $whoisService = new \App\Services\WhoisService(); + $expirationDate = $domain['expiration_date'] ?? null; $statusArray = $whoisData['status'] ?? []; - // Check if domain is available - foreach ($statusArray as $statusLine) { - if (stripos($statusLine, 'AVAILABLE') !== false || stripos($statusLine, 'FREE') !== false) { - return 'available'; - } - } - - // Determine from days left - if ($domain['daysLeft'] !== null) { - if ($domain['daysLeft'] < 0) return 'expired'; - if ($domain['daysLeft'] <= 30) return 'expiring_soon'; - return 'active'; - } - - return 'error'; + return $whoisService->getDomainStatus($expirationDate, $statusArray, $whoisData); } /** diff --git a/app/Models/SessionManager.php b/app/Models/SessionManager.php index 3a5688f..daa1b21 100644 --- a/app/Models/SessionManager.php +++ b/app/Models/SessionManager.php @@ -120,7 +120,11 @@ class SessionManager extends Model ]; } catch (\Exception $e) { - error_log("Geolocation lookup failed: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->warning('Geolocation lookup failed', [ + 'ip' => $ip, + 'error' => $e->getMessage() + ]); return self::getDefaultGeolocation(); } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 8ae24fa..775e3c3 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -122,7 +122,7 @@ class Setting extends Model */ public function getAppVersion(): string { - return $this->getValue('app_version', '1.1.0'); + return $this->getValue('app_version', '1.1.1'); } /** diff --git a/app/Services/CaptchaService.php b/app/Services/CaptchaService.php index 4545380..ae5d023 100644 --- a/app/Services/CaptchaService.php +++ b/app/Services/CaptchaService.php @@ -53,7 +53,8 @@ class CaptchaService default: // Unknown provider - allow through but log - error_log("Unknown CAPTCHA provider: $provider"); + $logger = new \App\Services\Logger(); + $logger->warning('Unknown CAPTCHA provider', ['provider' => $provider]); return ['success' => true, 'error' => null, 'score' => null]; } } @@ -66,7 +67,8 @@ class CaptchaService $secretKey = $this->captchaSettings['secret_key'] ?? ''; if (empty($secretKey)) { - error_log('reCAPTCHA v2 secret key is not configured'); + $logger = new \App\Services\Logger(); + $logger->error('reCAPTCHA v2 secret key is not configured'); return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; } @@ -87,7 +89,8 @@ class CaptchaService if (!isset($result['success']) || !$result['success']) { $errorCodes = $result['error-codes'] ?? []; - error_log('reCAPTCHA v2 verification failed: ' . json_encode($errorCodes)); + $logger = new \App\Services\Logger(); + $logger->warning('reCAPTCHA v2 verification failed', ['error_codes' => $errorCodes]); return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; } @@ -103,7 +106,8 @@ class CaptchaService $threshold = floatval($this->captchaSettings['score_threshold'] ?? 0.5); if (empty($secretKey)) { - error_log('reCAPTCHA v3 secret key is not configured'); + $logger = new \App\Services\Logger(); + $logger->error('reCAPTCHA v3 secret key is not configured'); return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; } @@ -124,7 +128,8 @@ class CaptchaService if (!isset($result['success']) || !$result['success']) { $errorCodes = $result['error-codes'] ?? []; - error_log('reCAPTCHA v3 verification failed: ' . json_encode($errorCodes)); + $logger = new \App\Services\Logger(); + $logger->warning('reCAPTCHA v3 verification failed', ['error_codes' => $errorCodes]); return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; } @@ -132,7 +137,12 @@ class CaptchaService $score = floatval($result['score'] ?? 0); if ($score < $threshold) { - error_log("reCAPTCHA v3 score too low: $score (threshold: $threshold)"); + $logger = new \App\Services\Logger(); + $logger->warning('reCAPTCHA v3 score too low', [ + 'score' => $score, + 'threshold' => $threshold, + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ]); return ['success' => false, 'error' => 'Security verification failed. Please try again or contact support.', 'score' => $score]; } @@ -147,7 +157,8 @@ class CaptchaService $secretKey = $this->captchaSettings['secret_key'] ?? ''; if (empty($secretKey)) { - error_log('Turnstile secret key is not configured'); + $logger = new \App\Services\Logger(); + $logger->error('Turnstile secret key is not configured'); return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; } @@ -168,7 +179,8 @@ class CaptchaService if (!isset($result['success']) || !$result['success']) { $errorCodes = $result['error-codes'] ?? []; - error_log('Turnstile verification failed: ' . json_encode($errorCodes)); + $logger = new \App\Services\Logger(); + $logger->warning('Turnstile verification failed', ['error_codes' => $errorCodes]); return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; } @@ -193,14 +205,18 @@ class CaptchaService $response = @file_get_contents($url, false, $context); if ($response === false) { - error_log("Failed to connect to CAPTCHA verification service: $url"); + $logger = new \App\Services\Logger(); + $logger->error('Failed to connect to CAPTCHA verification service', ['url' => $url]); return null; } $result = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { - error_log("Failed to parse CAPTCHA verification response: " . json_last_error_msg()); + $logger = new \App\Services\Logger(); + $logger->error('Failed to parse CAPTCHA verification response', [ + 'error' => json_last_error_msg() + ]); return null; } diff --git a/app/Services/Channels/PushoverChannel.php b/app/Services/Channels/PushoverChannel.php new file mode 100644 index 0000000..6f13e15 --- /dev/null +++ b/app/Services/Channels/PushoverChannel.php @@ -0,0 +1,186 @@ +client = new Client(['timeout' => 10]); + $this->logger = new Logger('pushover_channel'); + } + + public function send(array $config, string $message, array $data = []): bool + { + // Required configuration + if (!isset($config['api_token']) || !isset($config['user_key'])) { + $this->logger->error('Pushover configuration incomplete', [ + 'has_api_token' => isset($config['api_token']), + 'has_user_key' => isset($config['user_key']) + ]); + return false; + } + + try { + // Determine priority based on days left + $priority = $this->getPriorityByDaysLeft($data['days_left'] ?? null); + + // Build request payload + $payload = [ + 'token' => $config['api_token'], // Your application's API token + 'user' => $config['user_key'], // User/group key + 'message' => $message, + 'priority' => $priority, + ]; + + // Optional: Add title + if (isset($data['domain'])) { + $payload['title'] = '🔔 Domain Expiration Alert: ' . $data['domain']; + } else { + $payload['title'] = '🔔 Domain Monitor Notification'; + } + + // Optional: Add device (if configured) + if (!empty($config['device'])) { + $payload['device'] = $config['device']; + } + + // Optional: Add sound (if configured) + if (!empty($config['sound'])) { + $payload['sound'] = $config['sound']; + } else { + // Default sounds based on priority + $payload['sound'] = $this->getSoundByPriority($priority); + } + + // Optional: Add URL for domain link + if (isset($data['domain_id'])) { + $baseUrl = $_ENV['APP_URL'] ?? 'http://localhost'; + $payload['url'] = rtrim($baseUrl, '/') . '/domains/' . $data['domain_id']; + $payload['url_title'] = 'View Domain Details'; + } + + // For emergency priority (2), add retry and expire parameters + if ($priority === 2) { + $payload['retry'] = 300; // Retry every 5 minutes + $payload['expire'] = 3600; // Give up after 1 hour + } + + // Add timestamp + $payload['timestamp'] = time(); + + // Optional: Add HTML formatting if message contains line breaks + if (strpos($message, "\n") !== false) { + $payload['html'] = 1; + $payload['message'] = nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')); + } + + $response = $this->client->post(self::API_URL, [ + 'form_params' => $payload + ]); + + $statusCode = $response->getStatusCode(); + $body = json_decode($response->getBody()->getContents(), true); + + if ($statusCode === 200 && isset($body['status']) && $body['status'] === 1) { + $this->logger->info('Pushover message sent successfully', [ + 'status' => $statusCode, + 'request_id' => $body['request'] ?? null, + 'priority' => $priority + ]); + return true; + } else { + $this->logger->error('Pushover API returned non-success status', [ + 'status' => $statusCode, + 'response' => $body + ]); + return false; + } + + } catch (\GuzzleHttp\Exception\ClientException $e) { + // Handle 4xx errors (authentication, invalid parameters, etc.) + $response = $e->getResponse(); + $body = json_decode($response->getBody()->getContents(), true); + + $this->logger->error('Pushover client error', [ + 'status' => $response->getStatusCode(), + 'errors' => $body['errors'] ?? [], + 'message' => $e->getMessage() + ]); + return false; + + } catch (\Exception $e) { + $this->logger->error('Pushover send failed', [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + return false; + } + } + + /** + * Get priority based on days left until expiration + * + * Pushover priorities: + * -2 = Lowest (no notification/alert) + * -1 = Low (no sound or vibration) + * 0 = Normal (default) + * 1 = High (bypasses quiet hours) + * 2 = Emergency (requires acknowledgement) + */ + private function getPriorityByDaysLeft(?int $daysLeft): int + { + if ($daysLeft === null) { + return 0; // Normal priority for unknown + } + + if ($daysLeft <= 0) { + return 2; // Emergency - Domain expired! + } + + if ($daysLeft <= 1) { + return 2; // Emergency - Expires tomorrow or today + } + + if ($daysLeft <= 3) { + return 1; // High - Expires very soon + } + + if ($daysLeft <= 7) { + return 1; // High - Expires this week + } + + if ($daysLeft <= 14) { + return 0; // Normal - Expires within 2 weeks + } + + return -1; // Low priority for longer timeframes + } + + /** + * Get appropriate sound based on priority + * + * Available sounds: pushover, bike, bugle, cashregister, classical, cosmic, + * falling, gamelan, incoming, intermission, magic, mechanical, pianobar, + * siren, spacealarm, tugboat, alien, climb, persistent, echo, updown, vibrate, none + */ + private function getSoundByPriority(int $priority): string + { + return match($priority) { + 2 => 'siren', // Emergency + 1 => 'persistent', // High + 0 => 'pushover', // Normal (default) + -1 => 'gamelan', // Low + -2 => 'none', // Lowest + default => 'pushover' // Fallback + }; + } +} + diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 066e589..e312e89 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -8,6 +8,7 @@ use App\Services\Channels\DiscordChannel; use App\Services\Channels\SlackChannel; use App\Services\Channels\MattermostChannel; use App\Services\Channels\WebhookChannel; +use App\Services\Channels\PushoverChannel; class NotificationService { @@ -22,6 +23,7 @@ class NotificationService 'slack' => new SlackChannel(), 'mattermost' => new MattermostChannel(), 'webhook' => new WebhookChannel(), + 'pushover' => new PushoverChannel(), ]; } @@ -37,7 +39,11 @@ class NotificationService try { return $this->channels[$channelType]->send($config, $message, $data); } catch (\Exception $e) { - error_log("Notification send failed [$channelType]: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error("Notification send failed", [ + 'channel_type' => $channelType, + 'error' => $e->getMessage() + ]); return false; } } @@ -119,7 +125,7 @@ class NotificationService private function formatExpirationMessage(array $domain, int $daysLeft): string { $domainName = $domain['domain_name']; - $expirationDate = date('F j, Y', strtotime($domain['expiration_date'])); + $expirationDate = $domain['expiration_date'] ? date('F j, Y', strtotime($domain['expiration_date'])) : 'Unknown'; $registrar = $domain['registrar'] ?? 'Unknown'; if ($daysLeft <= 0) { @@ -285,7 +291,10 @@ class NotificationService $this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount); } } catch (\Exception $e) { - error_log("Failed to notify admins about upgrade: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error("Failed to notify admins about upgrade", [ + 'error' => $e->getMessage() + ]); } } @@ -303,7 +312,10 @@ class NotificationService ); $stmt->execute([$daysOld]); } catch (\Exception $e) { - error_log("Failed to clean old notifications: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error("Failed to clean old notifications", [ + 'error' => $e->getMessage() + ]); } } } diff --git a/app/Services/WhoisService.php b/app/Services/WhoisService.php index 5c46442..3fe28c7 100644 --- a/app/Services/WhoisService.php +++ b/app/Services/WhoisService.php @@ -65,7 +65,12 @@ class WhoisService if ($rdapUrl) { $rdapData = $this->queryRDAPGeneric($domain, $rdapUrl); if ($rdapData) { - error_log("RDAP Success for $domain - Status: " . json_encode($rdapData['status'] ?? []) . " | Registrar: " . ($rdapData['registrar'] ?? 'null')); + $logger = new \App\Services\Logger(); + $logger->debug("RDAP Success", [ + 'domain' => $domain, + 'status' => $rdapData['status'] ?? [], + 'registrar' => $rdapData['registrar'] ?? 'null' + ]); // If RDAP succeeded but is missing expiration date, try WHOIS as fallback // But only if the domain is not already marked as available $isAvailable = false; @@ -88,13 +93,20 @@ class WhoisService } if ($whoisData) { - // Parse WHOIS data to get expiration date + // Parse WHOIS data to get expiration date and cleaner registrar $whoisInfo = $this->parseWhoisData($domain, $whoisData, $referralServer ?? $whoisServer); // Merge expiration date from WHOIS into RDAP data if (!empty($whoisInfo['expiration_date'])) { $rdapData['expiration_date'] = $whoisInfo['expiration_date']; } + + // Also merge registrar if WHOIS has a cleaner version (without "Name:" prefix) + if (!empty($whoisInfo['registrar']) && + $whoisInfo['registrar'] !== 'Unknown' && + (!empty($rdapData['registrar']) && strpos($rdapData['registrar'], 'Name:') !== false)) { + $rdapData['registrar'] = $whoisInfo['registrar']; + } } } } @@ -416,11 +428,21 @@ class WhoisService curl_close($ch); // Debug logging for RDAP requests - error_log("RDAP Request: $rdapUrl | HTTP: $httpCode | Response Length: " . strlen($response)); + $logger = new \App\Services\Logger(); + $logger->debug("RDAP Request", [ + 'url' => $rdapUrl, + 'http_code' => $httpCode, + 'response_length' => strlen($response) + ]); + if ($httpCode === 200 && $response) { $data = json_decode($response, true); if ($data) { - error_log("RDAP Success - Domain: $domain | Status: " . json_encode($data['status'] ?? []) . " | Entities: " . count($data['entities'] ?? [])); + $logger->debug("RDAP Success", [ + 'domain' => $domain, + 'status' => $data['status'] ?? [], + 'entities_count' => count($data['entities'] ?? []) + ]); } } @@ -560,7 +582,12 @@ class WhoisService foreach ($entity['vcardArray'][1] as $vcardField) { if (is_array($vcardField) && count($vcardField) >= 4) { if ($vcardField[0] === 'fn') { - $info['registrar'] = $vcardField[3]; + $registrarName = $vcardField[3]; + // .eu RDAP returns "Name: Company Name" - strip "Name:" prefix + if (preg_match('/^Name:\s*(.+)/i', $registrarName, $matches)) { + $registrarName = trim($matches[1]); + } + $info['registrar'] = $registrarName; } elseif ($vcardField[0] === 'url') { $info['registrar_url'] = $vcardField[3]; } @@ -624,7 +651,13 @@ class WhoisService $fp = @fsockopen($server, $port, $errno, $errstr, $timeout); if (!$fp) { - error_log("WHOIS connection failed to $server: $errstr ($errno)"); + $logger = new \App\Services\Logger(); + $logger->warning("WHOIS connection failed", [ + 'server' => $server, + 'port' => $port, + 'error' => $errstr, + 'errno' => $errno + ]); return null; } @@ -666,9 +699,17 @@ class WhoisService // Check if domain is not found/available $whoisDataLower = strtolower($whoisData); // More specific patterns to avoid false positives - if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered)$/m', $whoisDataLower) || - preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered)$/m', $whoisDataLower) || - preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered)$/m', $whoisDataLower)) { + if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) || + preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) || + preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower)) { + $data['status'][] = 'AVAILABLE'; + $data['registrar'] = 'Not Registered'; + return $data; + } + + // Special handling for .eu domains that are available + // EURid returns "Status: AVAILABLE" in a specific format + if (preg_match('/status:\s*available/i', $whoisDataLower)) { $data['status'][] = 'AVAILABLE'; $data['registrar'] = 'Not Registered'; return $data; @@ -696,6 +737,10 @@ class WhoisService // Extract registrar name (remove [Tag = XXX] part) $registrarName = preg_replace('/\s*\[Tag\s*=\s*[^\]]+\]/i', '', $nextLine); $registrarName = trim($registrarName); + // .eu format: Strip "Name:" prefix if present + if (preg_match('/^Name:\s*(.+)/i', $registrarName, $matches)) { + $registrarName = trim($matches[1]); + } if (!empty($registrarName)) { $data['registrar'] = $registrarName; $registrarFound = true; @@ -749,10 +794,23 @@ class WhoisService !preg_match('/@/', $value) && !preg_match('/^\d+$/', $value) && strlen($value) > 3) { - $data['registrar'] = $value; - $registrarFound = true; + + // .eu format: If value starts with "Name:", extract just the name part + if (preg_match('/^Name:\s*(.+)/i', $value, $matches)) { + $data['registrar'] = trim($matches[1]); + $registrarFound = true; + } else { + $data['registrar'] = $value; + $registrarFound = true; + } } } + + // .eu specific registrar format: "Name: Registrar Name" (as separate line) + if (!$registrarFound && $key === 'name' && $currentSection === 'registrar' && !empty($value)) { + $data['registrar'] = $value; + $registrarFound = true; + } // Nameservers (standard format) if (preg_match('/(name server|nserver|nameserver)/i', $key) && !empty($value)) { @@ -861,8 +919,12 @@ class WhoisService /** * Get domain status based on expiration and WHOIS status + * + * @param string|null $expirationDate The domain expiration date + * @param array $statusArray WHOIS/RDAP status flags + * @param array $whoisData Full WHOIS data (optional, for additional checks) */ - public function getDomainStatus(?string $expirationDate, array $statusArray = []): string + public function getDomainStatus(?string $expirationDate, array $statusArray = [], array $whoisData = []): string { // Check if domain is available (not registered) foreach ($statusArray as $status) { @@ -874,34 +936,70 @@ class WhoisService } } - // Also check if expiration date is null and no status indicates it's registered - if ($expirationDate === null && empty($statusArray)) { - return 'available'; - } - // If domain has "active" status but no expiration date, consider it active - // This handles TLDs like .nl that don't provide expiration dates + // This handles TLDs like .nl that don't provide expiration dates via RDAP foreach ($statusArray as $status) { if (stripos($status, 'active') !== false) { return 'active'; } } - $days = $this->daysUntilExpiration($expirationDate); - - if ($days === null) { - return 'error'; + // Check for other positive status indicators (domain is registered) + $registeredIndicators = ['ok', 'registered', 'client', 'server']; + foreach ($statusArray as $status) { + foreach ($registeredIndicators as $indicator) { + if (stripos($status, $indicator) !== false) { + // Domain has a registered status, check expiration + if ($expirationDate === null) { + // Has registered status but no expiration date (like .nl domains) + return 'active'; + } + break 2; // Exit both loops + } + } } - if ($days < 0) { - return 'expired'; + // Check if domain has nameservers (strong indicator it's registered) + // This handles TLDs like .eu that don't provide status or expiration dates + if (!empty($whoisData['nameservers']) && count($whoisData['nameservers']) > 0) { + // Domain has nameservers, so it's registered and active + return 'active'; } - if ($days <= 30) { - return 'expiring_soon'; + // Check if domain has a registrar that's not "Unknown" or "Not Registered" + // Another indicator the domain is registered + if (!empty($whoisData['registrar']) && + $whoisData['registrar'] !== 'Unknown' && + $whoisData['registrar'] !== 'Not Registered') { + // Has a valid registrar, likely registered + if ($expirationDate === null) { + return 'active'; + } } - return 'active'; + // If we have an expiration date, use it to determine status + if ($expirationDate !== null) { + $days = $this->daysUntilExpiration($expirationDate); + + if ($days === null) { + return 'error'; + } + + if ($days < 0) { + return 'expired'; + } + + if ($days <= 30) { + return 'expiring_soon'; + } + + return 'active'; + } + + // No expiration date and no clear status indicators + // This should only happen for newly added domains or error cases + // Return error to avoid incorrectly marking registered domains as available + return 'error'; } /** @@ -920,7 +1018,7 @@ class WhoisService ]; } - $status = $this->getDomainStatus($info['expiration_date'], $info['status']); + $status = $this->getDomainStatus($info['expiration_date'], $info['status'], $info); return [ 'domain' => $domain, diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index a27f897..4f575ab 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -245,7 +245,7 @@ ob_start();

- + days diff --git a/app/Views/domains/edit.php b/app/Views/domains/edit.php index 3c63317..a5cd4d5 100644 --- a/app/Views/domains/edit.php +++ b/app/Views/domains/edit.php @@ -128,7 +128,7 @@ ob_start();

- Current expiration date: + Current expiration date:

diff --git a/app/Views/domains/index.php b/app/Views/domains/index.php index 08e665c..bd4a77e 100644 --- a/app/Views/domains/index.php +++ b/app/Views/domains/index.php @@ -367,7 +367,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's

- + diff --git a/app/Views/domains/view.php b/app/Views/domains/view.php index a6cc016..40d7385 100644 --- a/app/Views/domains/view.php +++ b/app/Views/domains/view.php @@ -164,7 +164,7 @@ ob_start();

-

+

diff --git a/app/Views/groups/create.php b/app/Views/groups/create.php index 6fa74df..a745da9 100644 --- a/app/Views/groups/create.php +++ b/app/Views/groups/create.php @@ -81,7 +81,7 @@ ob_start();
+ + +