From 2b4035dd299f73add3c5638d7d2d021871a18cfe Mon Sep 17 00:00:00 2001 From: Hosteroid Date: Tue, 18 Nov 2025 13:22:49 +0200 Subject: [PATCH] Add Pushover notification channel and improve status detection Introduces Pushover as a new notification channel with priority-based alerts, device targeting, and custom sounds. Enhances domain status detection for .nl and .eu domains, ensuring accurate handling when expiration dates or explicit status flags are missing. Fixes PHP 8.x compatibility issues with null parameters in date functions and improves error handling and logging by replacing error_log() with a centralized Logger service. Updates documentation and migrations for version 1.1.1. --- CHANGELOG.md | 43 ++++ README.md | 4 +- app/Controllers/AuthController.php | 32 ++- app/Controllers/DomainController.php | 8 +- app/Controllers/InstallerController.php | 16 +- .../NotificationGroupController.php | 36 ++++ app/Controllers/ProfileController.php | 6 +- app/Controllers/UserController.php | 6 +- app/Helpers/DomainHelper.php | 22 +-- app/Models/SessionManager.php | 6 +- app/Models/Setting.php | 2 +- app/Services/CaptchaService.php | 36 +++- app/Services/Channels/PushoverChannel.php | 186 ++++++++++++++++++ app/Services/NotificationService.php | 20 +- app/Services/WhoisService.php | 154 ++++++++++++--- app/Views/dashboard/index.php | 2 +- app/Views/domains/edit.php | 2 +- app/Views/domains/index.php | 2 +- app/Views/domains/view.php | 2 +- app/Views/groups/create.php | 2 +- app/Views/groups/edit.php | 105 +++++++++- app/Views/groups/index.php | 2 +- app/Views/search/results.php | 2 +- app/Views/tags/view.php | 4 +- core/Auth.php | 11 +- cron/check_domains.php | 8 +- .../migrations/000_initial_schema_v1.1.0.sql | 4 +- .../022_add_pushover_channel_type.sql | 6 + .../023_update_app_version_to_1.1.1.sql | 7 + database/migrations/README.md | 7 + public/index.php | 38 +++- 31 files changed, 684 insertions(+), 97 deletions(-) create mode 100644 app/Services/Channels/PushoverChannel.php create mode 100644 database/migrations/022_add_pushover_channel_type.sql create mode 100644 database/migrations/023_update_app_version_to_1.1.1.sql 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();
  • - After creating the group, you'll be able to add notification channels (Email, Telegram, Discord, Slack) + After creating the group, you'll be able to add notification channels (Email, Telegram, Discord, Slack, Mattermost, Pushover, Webhook)
  • diff --git a/app/Views/groups/edit.php b/app/Views/groups/edit.php index dc2276c..27e8211 100644 --- a/app/Views/groups/edit.php +++ b/app/Views/groups/edit.php @@ -77,9 +77,9 @@ ob_start();
    'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack', 'mattermost' => 'fa-comments', 'webhook' => 'fa-link']; - $iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab', 'mattermost' => 'fas', 'webhook' => 'fas']; - $colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal', 'mattermost' => 'green', 'webhook' => 'purple']; + $icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack', 'mattermost' => 'fa-comments', 'pushover' => 'fa-mobile-alt', 'webhook' => 'fa-link']; + $iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab', 'mattermost' => 'fas', 'pushover' => 'fas', 'webhook' => 'fas']; + $colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal', 'mattermost' => 'green', 'pushover' => 'red', 'webhook' => 'purple']; $icon = $icons[$channel['channel_type']] ?? 'fa-bell'; $iconClass = $iconClasses[$channel['channel_type']] ?? 'fas'; $color = $colors[$channel['channel_type']] ?? 'gray'; @@ -106,6 +106,8 @@ ob_start(); echo htmlspecialchars($config['email'] ?? 'No email'); } elseif ($channel['channel_type'] === 'telegram') { echo "Chat: " . htmlspecialchars($config['chat_id'] ?? 'N/A'); + } elseif ($channel['channel_type'] === 'pushover') { + echo "User: " . substr(htmlspecialchars($config['user_key'] ?? 'N/A'), 0, 10) . "..."; } else { echo "Webhook configured"; } @@ -164,6 +166,7 @@ ob_start(); +
    @@ -267,6 +270,90 @@ ob_start();
+ + +