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.
This commit is contained in:
Hosteroid
2025-11-18 13:22:49 +02:00
parent 5b932aa565
commit 2b4035dd29
31 changed files with 684 additions and 97 deletions

View File

@@ -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/), 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). 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 ## [1.1.0] - 2025-10-09
### Added ### Added

View File

@@ -6,7 +6,7 @@
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![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 ## ✨ 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 - 📋 **Domain Management** - Add, edit, and monitor unlimited domains
- 🔍 **Smart WHOIS/RDAP Lookup** - Automatically fetches expiration dates and registrar information - 🔍 **Smart WHOIS/RDAP Lookup** - Automatically fetches expiration dates and registrar information
- 🗂️ **TLD Registry System** - Built-in support for 1,400+ TLDs with IANA integration - 🗂️ **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 - 👥 **Notification Groups** - Organize channels and assign domains flexibly
-**Real-time Dashboard** - Overview of all domains and their status -**Real-time Dashboard** - Overview of all domains and their status
- 📊 **Notification Logs** - Complete history of all sent notifications - 📊 **Notification Logs** - Complete history of all sent notifications

View File

@@ -94,7 +94,11 @@ class AuthController extends Controller
} }
if (!$user) { 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'; $_SESSION['error'] = 'Invalid username or password';
$this->redirect('/login'); $this->redirect('/login');
return; return;
@@ -102,14 +106,22 @@ class AuthController extends Controller
// Verify password // Verify password
if (!$this->userModel->verifyPassword($password, $user['password'])) { if (!$this->userModel->verifyPassword($password, $user['password'])) {
error_log("Login failed: Password verification failed for user '$username'"); $logger = new \App\Services\Logger();
error_log("Stored hash: {$user['password']}"); $logger->warning("Login failed - Password verification failed", [
'username' => $username,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
$_SESSION['error'] = 'Invalid username or password'; $_SESSION['error'] = 'Invalid username or password';
$this->redirect('/login'); $this->redirect('/login');
return; 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 // Check if email verification is required
$requireVerification = $this->settingModel->getValue('require_email_verification'); $requireVerification = $this->settingModel->getValue('require_email_verification');
@@ -310,7 +322,11 @@ class AuthController extends Controller
$notificationService->notifyWelcome($userId, $username); $notificationService->notifyWelcome($userId, $username);
} catch (\Exception $e) { } catch (\Exception $e) {
// Don't fail registration if notification fails // 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 // Check if email verification is required
@@ -684,7 +700,11 @@ class AuthController extends Controller
} catch (\Exception $e) { } catch (\Exception $e) {
// Silently fail - remember me is not critical // 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()
]);
} }
} }

View File

@@ -212,7 +212,7 @@ class DomainController extends Controller
} }
// Create domain // 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) // Warn if domain is available (not registered)
if ($status === 'available') { if ($status === 'available') {
@@ -462,7 +462,7 @@ class DomainController extends Controller
// Use WHOIS expiration date if available, otherwise preserve manual expiration date // Use WHOIS expiration date if available, otherwise preserve manual expiration date
$expirationDate = $whoisData['expiration_date'] ?? $domain['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, [ $this->domainModel->update($id, [
'registrar' => $whoisData['registrar'], 'registrar' => $whoisData['registrar'],
@@ -673,7 +673,7 @@ class DomainController extends Controller
continue; continue;
} }
$status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); $status = $this->whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? [], $whoisData);
// Track available domains // Track available domains
if ($status === 'available') { if ($status === 'available') {
@@ -792,7 +792,7 @@ class DomainController extends Controller
// Use WHOIS expiration date if available, otherwise preserve manual expiration date // Use WHOIS expiration date if available, otherwise preserve manual expiration date
$expirationDate = $whoisData['expiration_date'] ?? $domain['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, [ $this->domainModel->update($id, [
'registrar' => $whoisData['registrar'], 'registrar' => $whoisData['registrar'],

View File

@@ -52,6 +52,8 @@ class InstallerController extends Controller
'019_add_webhook_channel_type.sql', '019_add_webhook_channel_type.sql',
'020_create_tags_system.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',
]; ];
try { try {
@@ -175,7 +177,7 @@ class InstallerController extends Controller
$stmt->execute([$migration]); $stmt->execute([$migration]);
} }
// Return only new migrations for v1.1.0 // Return only new migrations for v1.1.x
return [ return [
'009_add_authentication_features.sql', '009_add_authentication_features.sql',
'010_add_app_version_setting.sql', '010_add_app_version_setting.sql',
@@ -189,7 +191,9 @@ class InstallerController extends Controller
'018_add_user_isolation.sql', '018_add_user_isolation.sql',
'019_add_webhook_channel_type.sql', '019_add_webhook_channel_type.sql',
'020_create_tags_system.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', '019_add_webhook_channel_type.sql',
'020_create_tags_system.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',
]; ];
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); $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 // Determine from/to versions based on migrations
$fromVersion = '1.0.0'; $fromVersion = '1.0.0';
$toVersion = '1.1.0'; $toVersion = '1.1.1';
// Detect version based on which migrations were run // 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('012_link_remember_tokens_to_sessions.sql', $executed) ||
in_array('013_create_user_notifications_table.sql', $executed)) { in_array('013_create_user_notifications_table.sql', $executed)) {
$toVersion = '1.1.0'; $toVersion = '1.1.0';

View File

@@ -305,6 +305,9 @@ class NotificationGroupController extends Controller
case 'webhook': case 'webhook':
$missingField = 'webhook URL'; $missingField = 'webhook URL';
break; break;
case 'pushover':
$missingField = empty($_POST['pushover_api_token']) ? 'API token' : 'user key';
break;
} }
$_SESSION['error'] = "Invalid channel configuration: Missing {$missingField}"; $_SESSION['error'] = "Invalid channel configuration: Missing {$missingField}";
@@ -545,6 +548,39 @@ class NotificationGroupController extends Controller
} }
return ['webhook_url' => $webhookUrl]; 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': case 'webhook':
$webhookUrl = trim($data['webhook_url'] ?? ''); $webhookUrl = trim($data['webhook_url'] ?? '');
if (empty($webhookUrl) || !filter_var($webhookUrl, FILTER_VALIDATE_URL)) { if (empty($webhookUrl) || !filter_var($webhookUrl, FILTER_VALIDATE_URL)) {

View File

@@ -45,7 +45,11 @@ class ProfileController extends Controller
$this->sessionModel->cleanOldSessions(); $this->sessionModel->cleanOldSessions();
} catch (\Exception $e) { } catch (\Exception $e) {
// Silent fail - don't break the page // 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 // Get all active sessions

View File

@@ -179,7 +179,11 @@ class UserController extends Controller
$notificationService->notifyWelcome($userId, $username); $notificationService->notifyWelcome($userId, $username);
} catch (\Exception $e) { } catch (\Exception $e) {
// Don't fail user creation if notification fails // 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'; $_SESSION['success'] = 'User created successfully';

View File

@@ -64,6 +64,7 @@ class DomainHelper
/** /**
* Determine domain status from WHOIS data * Determine domain status from WHOIS data
* Uses WhoisService for consistent status detection across the application
*/ */
private static function determineStatus(array $domain): string private static function determineStatus(array $domain): string
{ {
@@ -76,23 +77,14 @@ class DomainHelper
// Parse WHOIS data // Parse WHOIS data
$whoisData = json_decode($domain['whois_data'] ?? '{}', true); $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'] ?? []; $statusArray = $whoisData['status'] ?? [];
// Check if domain is available return $whoisService->getDomainStatus($expirationDate, $statusArray, $whoisData);
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';
} }
/** /**

View File

@@ -120,7 +120,11 @@ class SessionManager extends Model
]; ];
} catch (\Exception $e) { } 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(); return self::getDefaultGeolocation();
} }
} }

View File

@@ -122,7 +122,7 @@ class Setting extends Model
*/ */
public function getAppVersion(): string public function getAppVersion(): string
{ {
return $this->getValue('app_version', '1.1.0'); return $this->getValue('app_version', '1.1.1');
} }
/** /**

View File

@@ -53,7 +53,8 @@ class CaptchaService
default: default:
// Unknown provider - allow through but log // 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]; return ['success' => true, 'error' => null, 'score' => null];
} }
} }
@@ -66,7 +67,8 @@ class CaptchaService
$secretKey = $this->captchaSettings['secret_key'] ?? ''; $secretKey = $this->captchaSettings['secret_key'] ?? '';
if (empty($secretKey)) { 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]; return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null];
} }
@@ -87,7 +89,8 @@ class CaptchaService
if (!isset($result['success']) || !$result['success']) { if (!isset($result['success']) || !$result['success']) {
$errorCodes = $result['error-codes'] ?? []; $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]; 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); $threshold = floatval($this->captchaSettings['score_threshold'] ?? 0.5);
if (empty($secretKey)) { 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]; return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null];
} }
@@ -124,7 +128,8 @@ class CaptchaService
if (!isset($result['success']) || !$result['success']) { if (!isset($result['success']) || !$result['success']) {
$errorCodes = $result['error-codes'] ?? []; $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]; return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null];
} }
@@ -132,7 +137,12 @@ class CaptchaService
$score = floatval($result['score'] ?? 0); $score = floatval($result['score'] ?? 0);
if ($score < $threshold) { 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]; 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'] ?? ''; $secretKey = $this->captchaSettings['secret_key'] ?? '';
if (empty($secretKey)) { 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]; return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null];
} }
@@ -168,7 +179,8 @@ class CaptchaService
if (!isset($result['success']) || !$result['success']) { if (!isset($result['success']) || !$result['success']) {
$errorCodes = $result['error-codes'] ?? []; $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]; 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); $response = @file_get_contents($url, false, $context);
if ($response === false) { 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; return null;
} }
$result = json_decode($response, true); $result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) { 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; return null;
} }

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Services\Channels;
use GuzzleHttp\Client;
use App\Services\Logger;
class PushoverChannel implements NotificationChannelInterface
{
private Client $client;
private Logger $logger;
private const API_URL = 'https://api.pushover.net/1/messages.json';
public function __construct()
{
$this->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
};
}
}

View File

@@ -8,6 +8,7 @@ use App\Services\Channels\DiscordChannel;
use App\Services\Channels\SlackChannel; use App\Services\Channels\SlackChannel;
use App\Services\Channels\MattermostChannel; use App\Services\Channels\MattermostChannel;
use App\Services\Channels\WebhookChannel; use App\Services\Channels\WebhookChannel;
use App\Services\Channels\PushoverChannel;
class NotificationService class NotificationService
{ {
@@ -22,6 +23,7 @@ class NotificationService
'slack' => new SlackChannel(), 'slack' => new SlackChannel(),
'mattermost' => new MattermostChannel(), 'mattermost' => new MattermostChannel(),
'webhook' => new WebhookChannel(), 'webhook' => new WebhookChannel(),
'pushover' => new PushoverChannel(),
]; ];
} }
@@ -37,7 +39,11 @@ class NotificationService
try { try {
return $this->channels[$channelType]->send($config, $message, $data); return $this->channels[$channelType]->send($config, $message, $data);
} catch (\Exception $e) { } 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; return false;
} }
} }
@@ -119,7 +125,7 @@ class NotificationService
private function formatExpirationMessage(array $domain, int $daysLeft): string private function formatExpirationMessage(array $domain, int $daysLeft): string
{ {
$domainName = $domain['domain_name']; $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'; $registrar = $domain['registrar'] ?? 'Unknown';
if ($daysLeft <= 0) { if ($daysLeft <= 0) {
@@ -285,7 +291,10 @@ class NotificationService
$this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount); $this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount);
} }
} catch (\Exception $e) { } 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]); $stmt->execute([$daysOld]);
} catch (\Exception $e) { } 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()
]);
} }
} }
} }

View File

@@ -65,7 +65,12 @@ class WhoisService
if ($rdapUrl) { if ($rdapUrl) {
$rdapData = $this->queryRDAPGeneric($domain, $rdapUrl); $rdapData = $this->queryRDAPGeneric($domain, $rdapUrl);
if ($rdapData) { 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 // If RDAP succeeded but is missing expiration date, try WHOIS as fallback
// But only if the domain is not already marked as available // But only if the domain is not already marked as available
$isAvailable = false; $isAvailable = false;
@@ -88,13 +93,20 @@ class WhoisService
} }
if ($whoisData) { 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); $whoisInfo = $this->parseWhoisData($domain, $whoisData, $referralServer ?? $whoisServer);
// Merge expiration date from WHOIS into RDAP data // Merge expiration date from WHOIS into RDAP data
if (!empty($whoisInfo['expiration_date'])) { if (!empty($whoisInfo['expiration_date'])) {
$rdapData['expiration_date'] = $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); curl_close($ch);
// Debug logging for RDAP requests // 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) { if ($httpCode === 200 && $response) {
$data = json_decode($response, true); $data = json_decode($response, true);
if ($data) { 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) { foreach ($entity['vcardArray'][1] as $vcardField) {
if (is_array($vcardField) && count($vcardField) >= 4) { if (is_array($vcardField) && count($vcardField) >= 4) {
if ($vcardField[0] === 'fn') { 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') { } elseif ($vcardField[0] === 'url') {
$info['registrar_url'] = $vcardField[3]; $info['registrar_url'] = $vcardField[3];
} }
@@ -624,7 +651,13 @@ class WhoisService
$fp = @fsockopen($server, $port, $errno, $errstr, $timeout); $fp = @fsockopen($server, $port, $errno, $errstr, $timeout);
if (!$fp) { 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; return null;
} }
@@ -666,9 +699,17 @@ class WhoisService
// Check if domain is not found/available // Check if domain is not found/available
$whoisDataLower = strtolower($whoisData); $whoisDataLower = strtolower($whoisData);
// More specific patterns to avoid false positives // 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) || 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)$/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)$/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['status'][] = 'AVAILABLE';
$data['registrar'] = 'Not Registered'; $data['registrar'] = 'Not Registered';
return $data; return $data;
@@ -696,6 +737,10 @@ class WhoisService
// Extract registrar name (remove [Tag = XXX] part) // Extract registrar name (remove [Tag = XXX] part)
$registrarName = preg_replace('/\s*\[Tag\s*=\s*[^\]]+\]/i', '', $nextLine); $registrarName = preg_replace('/\s*\[Tag\s*=\s*[^\]]+\]/i', '', $nextLine);
$registrarName = trim($registrarName); $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)) { if (!empty($registrarName)) {
$data['registrar'] = $registrarName; $data['registrar'] = $registrarName;
$registrarFound = true; $registrarFound = true;
@@ -749,10 +794,23 @@ class WhoisService
!preg_match('/@/', $value) && !preg_match('/@/', $value) &&
!preg_match('/^\d+$/', $value) && !preg_match('/^\d+$/', $value) &&
strlen($value) > 3) { 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) // Nameservers (standard format)
if (preg_match('/(name server|nserver|nameserver)/i', $key) && !empty($value)) { 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 * 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) // Check if domain is available (not registered)
foreach ($statusArray as $status) { 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 // 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) { foreach ($statusArray as $status) {
if (stripos($status, 'active') !== false) { if (stripos($status, 'active') !== false) {
return 'active'; return 'active';
} }
} }
$days = $this->daysUntilExpiration($expirationDate); // Check for other positive status indicators (domain is registered)
$registeredIndicators = ['ok', 'registered', 'client', 'server'];
if ($days === null) { foreach ($statusArray as $status) {
return 'error'; 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) { // Check if domain has nameservers (strong indicator it's registered)
return 'expired'; // 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) { // Check if domain has a registrar that's not "Unknown" or "Not Registered"
return 'expiring_soon'; // 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 [ return [
'domain' => $domain, 'domain' => $domain,

View File

@@ -245,7 +245,7 @@ ob_start();
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate"><?= htmlspecialchars($domain['domain_name']) ?></p> <p class="text-sm font-medium text-gray-900 truncate"><?= htmlspecialchars($domain['domain_name']) ?></p>
<p class="text-xs text-gray-500 mt-0.5"> <p class="text-xs text-gray-500 mt-0.5">
<?= date('M d, Y', strtotime($domain['expiration_date'])) ?> <?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
<span class="<?= $urgencyClass ?> font-semibold ml-2"> <span class="<?= $urgencyClass ?> font-semibold ml-2">
<?= $daysLeft ?> days <?= $daysLeft ?> days
</span> </span>

View File

@@ -128,7 +128,7 @@ ob_start();
<?php if ($domain['expiration_date']): ?> <?php if ($domain['expiration_date']): ?>
<p class="mt-1 text-xs text-green-600"> <p class="mt-1 text-xs text-green-600">
<i class="fas fa-check-circle mr-1"></i> <i class="fas fa-check-circle mr-1"></i>
Current expiration date: <?= date('M j, Y', strtotime($domain['expiration_date'])) ?> Current expiration date: <?= $domain['expiration_date'] ? date('M j, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
</p> </p>
<?php else: ?> <?php else: ?>
<p class="mt-1 text-xs text-amber-600"> <p class="mt-1 text-xs text-amber-600">

View File

@@ -367,7 +367,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<?php if (!empty($domain['expiration_date'])): ?> <?php if (!empty($domain['expiration_date'])): ?>
<div class="text-sm"> <div class="text-sm">
<div class="font-medium text-gray-900 flex items-center"> <div class="font-medium text-gray-900 flex items-center">
<?= date('M d, Y', strtotime($domain['expiration_date'])) ?> <?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
<?php if ($domain['isManualExpiration']): ?> <?php if ($domain['isManualExpiration']): ?>
<span class="ml-1 inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800" title="Manual expiration date"> <span class="ml-1 inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800" title="Manual expiration date">
<i class="fas fa-edit" style="font-size: 8px;"></i> <i class="fas fa-edit" style="font-size: 8px;"></i>

View File

@@ -164,7 +164,7 @@ ob_start();
</span> </span>
<?php endif; ?> <?php endif; ?>
</p> </p>
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y', strtotime($domain['expiration_date'])) ?></p> <p class="text-xs font-semibold text-gray-900"><?= $domain['expiration_date'] ? date('M j, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?></p>
</div> </div>
</div> </div>
<span class="px-2 py-1 bg-<?= $expiryColor ?>-100 text-<?= $expiryColor ?>-800 rounded text-xs font-bold"> <span class="px-2 py-1 bg-<?= $expiryColor ?>-100 text-<?= $expiryColor ?>-800 rounded text-xs font-bold">

View File

@@ -81,7 +81,7 @@ ob_start();
<ul class="text-xs text-gray-600 space-y-1"> <ul class="text-xs text-gray-600 space-y-1">
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i> <i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
<span class="ml-2">After creating the group, you'll be able to add notification channels (Email, Telegram, Discord, Slack)</span> <span class="ml-2">After creating the group, you'll be able to add notification channels (Email, Telegram, Discord, Slack, Mattermost, Pushover, Webhook)</span>
</li> </li>
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i> <i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>

View File

@@ -77,9 +77,9 @@ ob_start();
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<?php foreach ($group['channels'] as $channel): <?php foreach ($group['channels'] as $channel):
$config = json_decode($channel['channel_config'], true); $config = json_decode($channel['channel_config'], true);
$icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack', 'mattermost' => 'fa-comments', 'webhook' => 'fa-link']; $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', 'webhook' => 'fas']; $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', 'webhook' => 'purple']; $colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal', 'mattermost' => 'green', 'pushover' => 'red', 'webhook' => 'purple'];
$icon = $icons[$channel['channel_type']] ?? 'fa-bell'; $icon = $icons[$channel['channel_type']] ?? 'fa-bell';
$iconClass = $iconClasses[$channel['channel_type']] ?? 'fas'; $iconClass = $iconClasses[$channel['channel_type']] ?? 'fas';
$color = $colors[$channel['channel_type']] ?? 'gray'; $color = $colors[$channel['channel_type']] ?? 'gray';
@@ -106,6 +106,8 @@ ob_start();
echo htmlspecialchars($config['email'] ?? 'No email'); echo htmlspecialchars($config['email'] ?? 'No email');
} elseif ($channel['channel_type'] === 'telegram') { } elseif ($channel['channel_type'] === 'telegram') {
echo "Chat: " . htmlspecialchars($config['chat_id'] ?? 'N/A'); 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 { } else {
echo "Webhook configured"; echo "Webhook configured";
} }
@@ -164,6 +166,7 @@ ob_start();
<option value="discord">Discord</option> <option value="discord">Discord</option>
<option value="slack">Slack</option> <option value="slack">Slack</option>
<option value="mattermost">Mattermost</option> <option value="mattermost">Mattermost</option>
<option value="pushover">Pushover</option>
<option value="webhook">Webhook (Custom)</option> <option value="webhook">Webhook (Custom)</option>
</select> </select>
</div> </div>
@@ -267,6 +270,90 @@ ob_start();
</div> </div>
</div> </div>
<!-- Pushover Fields -->
<div id="pushover_fields" class="hidden space-y-4">
<div>
<label for="pushover_api_token" class="block text-sm font-medium text-gray-700 mb-1.5">
API Token (Application Key) <span class="text-red-500">*</span>
</label>
<input type="text"
id="pushover_api_token"
name="pushover_api_token"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi"
autocomplete="off">
<p class="mt-1.5 text-xs text-gray-500">
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
Create an application at <a href="https://pushover.net/apps/build" target="_blank" class="text-blue-600 hover:underline">pushover.net/apps/build</a>
</p>
</div>
<div>
<label for="pushover_user_key" class="block text-sm font-medium text-gray-700 mb-1.5">
User Key <span class="text-red-500">*</span>
</label>
<input type="text"
id="pushover_user_key"
name="pushover_user_key"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
autocomplete="off">
<p class="mt-1.5 text-xs text-gray-500">
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
Find your user key on your <a href="https://pushover.net/" target="_blank" class="text-blue-600 hover:underline">Pushover dashboard</a>
</p>
</div>
<div>
<label for="pushover_device" class="block text-sm font-medium text-gray-700 mb-1.5">
Device Name (Optional)
</label>
<input type="text"
id="pushover_device"
name="pushover_device"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Leave empty for all devices"
autocomplete="off">
<p class="mt-1.5 text-xs text-gray-500">
Specify a device name to send to specific device only (e.g., "iPhone", "Desktop")
</p>
</div>
<div>
<label for="pushover_sound" class="block text-sm font-medium text-gray-700 mb-1.5">
Notification Sound (Optional)
</label>
<select id="pushover_sound"
name="pushover_sound"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
<option value="">Default (based on priority)</option>
<option value="pushover">Pushover (default)</option>
<option value="bike">Bike</option>
<option value="bugle">Bugle</option>
<option value="cashregister">Cash Register</option>
<option value="classical">Classical</option>
<option value="cosmic">Cosmic</option>
<option value="falling">Falling</option>
<option value="gamelan">Gamelan</option>
<option value="incoming">Incoming</option>
<option value="intermission">Intermission</option>
<option value="magic">Magic</option>
<option value="mechanical">Mechanical</option>
<option value="pianobar">Piano Bar</option>
<option value="siren">Siren</option>
<option value="spacealarm">Space Alarm</option>
<option value="tugboat">Tugboat</option>
<option value="alien">Alien Alarm (long)</option>
<option value="climb">Climb (long)</option>
<option value="persistent">Persistent (long)</option>
<option value="echo">Pushover Echo (long)</option>
<option value="updown">Up Down (long)</option>
<option value="vibrate">Vibrate Only</option>
<option value="none">None (silent)</option>
</select>
<p class="mt-1.5 text-xs text-gray-500">
Custom sound for notifications. If not set, sound will be chosen based on urgency.
</p>
</div>
</div>
<!-- Generic Webhook Fields --> <!-- Generic Webhook Fields -->
<div id="webhook_fields" class="hidden space-y-4"> <div id="webhook_fields" class="hidden space-y-4">
<div> <div>
@@ -342,7 +429,7 @@ ob_start();
<h3 class="font-semibold text-gray-800 mb-2 truncate"><?= htmlspecialchars($domain['domain_name']) ?></h3> <h3 class="font-semibold text-gray-800 mb-2 truncate"><?= htmlspecialchars($domain['domain_name']) ?></h3>
<p class="text-sm text-gray-600 flex items-center"> <p class="text-sm text-gray-600 flex items-center">
<i class="far fa-calendar mr-2"></i> <i class="far fa-calendar mr-2"></i>
Expires: <?= date('M j, Y', strtotime($domain['expiration_date'])) ?> Expires: <?= $domain['expiration_date'] ? date('M j, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
</p> </p>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
@@ -364,6 +451,8 @@ function toggleChannelFields() {
const discordWebhook = document.getElementById('discord_webhook'); const discordWebhook = document.getElementById('discord_webhook');
const slackWebhook = document.getElementById('slack_webhook'); const slackWebhook = document.getElementById('slack_webhook');
const mattermostWebhook = document.getElementById('mattermost_webhook'); const mattermostWebhook = document.getElementById('mattermost_webhook');
const pushoverApiToken = document.getElementById('pushover_api_token');
const pushoverUserKey = document.getElementById('pushover_user_key');
const genericWebhook = document.getElementById('generic_webhook_url'); const genericWebhook = document.getElementById('generic_webhook_url');
// Remove required from all // Remove required from all
@@ -373,6 +462,8 @@ function toggleChannelFields() {
discordWebhook.removeAttribute('required'); discordWebhook.removeAttribute('required');
slackWebhook.removeAttribute('required'); slackWebhook.removeAttribute('required');
if (mattermostWebhook) mattermostWebhook.removeAttribute('required'); if (mattermostWebhook) mattermostWebhook.removeAttribute('required');
if (pushoverApiToken) pushoverApiToken.removeAttribute('required');
if (pushoverUserKey) pushoverUserKey.removeAttribute('required');
if (genericWebhook) genericWebhook.removeAttribute('required'); if (genericWebhook) genericWebhook.removeAttribute('required');
// Hide all fields // Hide all fields
@@ -381,6 +472,7 @@ function toggleChannelFields() {
document.getElementById('discord_fields').classList.add('hidden'); document.getElementById('discord_fields').classList.add('hidden');
document.getElementById('slack_fields').classList.add('hidden'); document.getElementById('slack_fields').classList.add('hidden');
document.getElementById('mattermost_fields').classList.add('hidden'); document.getElementById('mattermost_fields').classList.add('hidden');
document.getElementById('pushover_fields').classList.add('hidden');
document.getElementById('webhook_fields').classList.add('hidden'); document.getElementById('webhook_fields').classList.add('hidden');
// Hide test button by default // Hide test button by default
@@ -413,6 +505,11 @@ function toggleChannelFields() {
mattermostWebhook.focus(); mattermostWebhook.focus();
} }
break; break;
case 'pushover':
if (pushoverApiToken) pushoverApiToken.setAttribute('required', 'required');
if (pushoverUserKey) pushoverUserKey.setAttribute('required', 'required');
if (pushoverApiToken) pushoverApiToken.focus();
break;
case 'webhook': case 'webhook':
if (genericWebhook) { if (genericWebhook) {
genericWebhook.setAttribute('required', 'required'); genericWebhook.setAttribute('required', 'required');

View File

@@ -24,7 +24,7 @@ ob_start();
<h3 class="text-sm font-semibold text-gray-900 mb-1">About Notification Groups</h3> <h3 class="text-sm font-semibold text-gray-900 mb-1">About Notification Groups</h3>
<p class="text-xs text-gray-600 leading-relaxed"> <p class="text-xs text-gray-600 leading-relaxed">
Notification groups allow you to organize your notification channels. You can create multiple channels Notification groups allow you to organize your notification channels. You can create multiple channels
(Email, Telegram, Discord, Slack) within each group, then assign domains to the group. When a domain (Email, Telegram, Discord, Slack, Mattermost, Pushover, Webhook) within each group, then assign domains to the group. When a domain
is about to expire, all active channels in its group will receive notifications. is about to expire, all active channels in its group will receive notifications.
</p> </p>
</div> </div>

View File

@@ -90,7 +90,7 @@ ob_start();
<td class="px-6 py-4"> <td class="px-6 py-4">
<?php if (!empty($domain['expiration_date'])): ?> <?php if (!empty($domain['expiration_date'])): ?>
<div class="text-sm"> <div class="text-sm">
<div class="font-medium text-gray-900"><?= date('M d, Y', strtotime($domain['expiration_date'])) ?></div> <div class="font-medium text-gray-900"><?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?></div>
<div class="text-xs <?= $expiryClass ?>"> <div class="text-xs <?= $expiryClass ?>">
<?= $daysLeft ?> days <?= $daysLeft ?> days
</div> </div>

View File

@@ -203,7 +203,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'registrar' => ''
<?php if (!empty($domain['expiration_date'])): ?> <?php if (!empty($domain['expiration_date'])): ?>
<div class="text-sm"> <div class="text-sm">
<div class="font-medium text-gray-900 flex items-center"> <div class="font-medium text-gray-900 flex items-center">
<?= date('M d, Y', strtotime($domain['expiration_date'])) ?> <?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
</div> </div>
<div class="text-xs <?= $expiryClass ?>"> <div class="text-xs <?= $expiryClass ?>">
<?= $daysLeft ?> days <?= $daysLeft ?> days
@@ -282,7 +282,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'registrar' => ''
<?php if (!empty($domain['expiration_date'])): ?> <?php if (!empty($domain['expiration_date'])): ?>
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-calendar-alt text-gray-400 mr-2 w-4"></i> <i class="fas fa-calendar-alt text-gray-400 mr-2 w-4"></i>
<span>Expires: <?= date('M d, Y', strtotime($domain['expiration_date'])) ?> (<?= $daysLeft ?> days)</span> <span>Expires: <?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?> (<?= $daysLeft ?> days)</span>
</div> </div>
<?php endif; ?> <?php endif; ?>

View File

@@ -41,8 +41,15 @@ class Auth
*/ */
public static function require(): void public static function require(): void
{ {
// Get current path // Get current path - handle malformed URIs gracefully
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$currentPath = parse_url($requestUri, PHP_URL_PATH);
// If parse_url fails (malformed URI), treat as root path
// This prevents null from being passed to strpos()
if ($currentPath === null || $currentPath === false) {
$currentPath = '/';
}
// Public paths that don't require authentication // Public paths that don't require authentication
$publicPaths = [ $publicPaths = [

View File

@@ -115,12 +115,16 @@ foreach ($domains as $domain) {
continue; continue;
} }
// IMPORTANT: Use WHOIS expiration date if available, otherwise preserve existing expiration date
// This handles TLDs like .nl that don't provide expiration dates via RDAP
$expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date'];
// Update domain information // Update domain information
$status = $whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); $status = $whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData);
$domainModel->update($domain['id'], [ $domainModel->update($domain['id'], [
'registrar' => $whoisData['registrar'], 'registrar' => $whoisData['registrar'],
'registrar_url' => $whoisData['registrar_url'] ?? null, 'registrar_url' => $whoisData['registrar_url'] ?? null,
'expiration_date' => $whoisData['expiration_date'], 'expiration_date' => $expirationDate,
'updated_date' => $whoisData['updated_date'] ?? null, 'updated_date' => $whoisData['updated_date'] ?? null,
'abuse_email' => $whoisData['abuse_email'] ?? null, 'abuse_email' => $whoisData['abuse_email'] ?? null,
'last_checked' => date('Y-m-d H:i:s'), 'last_checked' => date('Y-m-d H:i:s'),

View File

@@ -225,7 +225,7 @@ ON DUPLICATE KEY UPDATE color = VALUES(color), description = VALUES(description)
CREATE TABLE IF NOT EXISTS notification_channels ( CREATE TABLE IF NOT EXISTS notification_channels (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
notification_group_id INT NOT NULL, notification_group_id INT NOT NULL,
channel_type ENUM('email', 'telegram', 'discord', 'slack', 'mattermost', 'webhook') NOT NULL, channel_type ENUM('email', 'telegram', 'discord', 'slack', 'mattermost', 'webhook', 'pushover') NOT NULL,
channel_config JSON NOT NULL, channel_config JSON NOT NULL,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -362,7 +362,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
('app_name', 'Domain Monitor', 'string', 'Application name'), ('app_name', 'Domain Monitor', 'string', 'Application name'),
('app_url', 'http://localhost:8000', 'string', 'Application URL'), ('app_url', 'http://localhost:8000', 'string', 'Application URL'),
('app_timezone', 'UTC', 'string', 'Application timezone'), ('app_timezone', 'UTC', 'string', 'Application timezone'),
('app_version', '1.1.0', 'string', 'Application version number'), ('app_version', '1.1.1', 'string', 'Application version number'),
-- Email settings -- Email settings
('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'), ('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'),

View File

@@ -0,0 +1,6 @@
-- Add 'pushover' to the channel_type ENUM in notification_channels table
-- This enables Pushover push notification support for domain expiration alerts
ALTER TABLE notification_channels
MODIFY COLUMN channel_type ENUM('email', 'telegram', 'discord', 'slack', 'mattermost', 'webhook', 'pushover') NOT NULL;

View File

@@ -0,0 +1,7 @@
-- Update application version to 1.1.1
-- This version includes Pushover notifications, security fixes, and improved domain status detection
UPDATE settings
SET setting_value = '1.1.1'
WHERE setting_key = 'app_version';

View File

@@ -28,6 +28,13 @@ If upgrading from v1.0.0, these incremental migrations will be applied:
- `014_add_captcha_settings.sql` - CAPTCHA settings (v2, v3, Turnstile) - `014_add_captcha_settings.sql` - CAPTCHA settings (v2, v3, Turnstile)
- `015_create_error_logs_table.sql` - Error logging and debugging system - `015_create_error_logs_table.sql` - Error logging and debugging system
- `016_add_tags_to_domains.sql` - Domain tags for organization - `016_add_tags_to_domains.sql` - Domain tags for organization
- `017_add_two_factor_authentication.sql` - Two-factor authentication (TOTP)
- `018_add_user_isolation.sql` - User isolation mode settings
- `019_add_webhook_channel_type.sql` - Webhook and Mattermost channel support
- `020_create_tags_system.sql` - Advanced tagging system for domains
- `021_add_avatar_field.sql` - User avatar field
- `022_add_pushover_channel_type.sql` - Pushover notification channel support
- `023_update_app_version_to_1.1.1.sql` - Update version to 1.1.1
**Upgrade via:** Web updater at `/install/update` **Upgrade via:** Web updater at `/install/update`

View File

@@ -12,6 +12,41 @@ define('PATH_ROOT', __DIR__ . '/../');
// Register global error handlers FIRST (before anything else can fail) // Register global error handlers FIRST (before anything else can fail)
ErrorHandler::register(); ErrorHandler::register();
// === EARLY REQUEST VALIDATION ===
// Block malformed requests before they cause issues
// This prevents null pointer errors and logs suspicious activity
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
// Validate REQUEST_URI format - reject if parse_url fails
$parsedPath = parse_url($requestUri, PHP_URL_PATH);
if ($parsedPath === null || $parsedPath === false) {
// Log the suspicious request
$logger = new \App\Services\Logger();
$logger->warning('Malformed REQUEST_URI blocked', [
'uri' => $requestUri,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
]);
// Return 400 Bad Request
http_response_code(400);
header('Content-Type: text/plain');
die('Bad Request: Invalid URI format');
}
// Additional validation: REQUEST_URI should start with /
if (!empty($requestUri) && $requestUri[0] !== '/') {
$logger = new \App\Services\Logger();
$logger->warning('Invalid REQUEST_URI - must start with /', [
'uri' => $requestUri,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
http_response_code(400);
header('Content-Type: text/plain');
die('Bad Request: Invalid URI format');
}
// Load environment variables (using safeLoad to not throw if missing) // Load environment variables (using safeLoad to not throw if missing)
$dotenv = Dotenv::createImmutable(__DIR__ . '/..'); $dotenv = Dotenv::createImmutable(__DIR__ . '/..');
try { try {
@@ -40,7 +75,8 @@ Core\SessionConfig::start();
require_once __DIR__ . '/../app/Helpers/CsrfHelper.php'; require_once __DIR__ . '/../app/Helpers/CsrfHelper.php';
// Check if system is installed (using flag file - no DB queries!) // Check if system is installed (using flag file - no DB queries!)
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); // Note: REQUEST_URI has already been validated above, so parse_url won't return null
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';
$isInstallerPath = strpos($currentPath, '/install') === 0; $isInstallerPath = strpos($currentPath, '/install') === 0;
$installedFlagFile = __DIR__ . '/../.installed'; $installedFlagFile = __DIR__ . '/../.installed';