Files
domnitor/app/Services/NotificationService.php
Hosteroid 5916daa293 Add SSL monitoring (Svc, model, cron, UI)
Introduce SSL certificate monitoring: add SslService for fetching/parsing certs and parsing monitor targets, SslCertificate model for storing snapshots and managing monitored targets, and cron/check_ssl.php for scheduled checks. Extend DomainController with many SSL endpoints and helpers (add/refresh/bulk refresh/delete/bulk delete, snapshot handling, formatting, stats, safety checks) and surface SSL data in domain views. Add NotificationService helpers to create/send SSL alerts, update Installer to include new migration, add migration 028 to create ssl_certificates table, bump app version default to 1.1.5, update changelog, and modify routes and templates to include SSL tab and related UI. Logs and basic validation/error handling are included to surface SSL issues and protect default root-target behavior.
2026-03-08 21:12:09 +02:00

882 lines
32 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Services\Channels\EmailChannel;
use App\Services\Channels\TelegramChannel;
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
{
private array $channels = [];
public function __construct()
{
$this->channels = [
'email' => new EmailChannel(),
'telegram' => new TelegramChannel(),
'discord' => new DiscordChannel(),
'slack' => new SlackChannel(),
'mattermost' => new MattermostChannel(),
'webhook' => new WebhookChannel(),
'pushover' => new PushoverChannel(),
];
}
/**
* Send notification to specified channel
*/
public function send(string $channelType, array $config, string $message, array $data = []): bool
{
if (!isset($this->channels[$channelType])) {
return false;
}
try {
return $this->channels[$channelType]->send($config, $message, $data);
} catch (\Exception $e) {
$logger = new \App\Services\Logger();
$logger->error("Notification send failed", [
'channel_type' => $channelType,
'error' => $e->getMessage()
]);
return false;
}
}
/**
* Send notification to all active channels in a group
*/
public function sendToGroup(int $groupId, string $subject, string $message, array $data = []): array
{
// Get active channels for the group
$channelModel = new \App\Models\NotificationChannel();
$channels = $channelModel->getByGroupId($groupId);
$results = [];
foreach ($channels as $channel) {
if (!$channel['is_active']) {
continue; // Skip inactive channels
}
$config = json_decode($channel['channel_config'], true);
// Add subject to data for channels that support it (like email)
$channelData = array_merge(['subject' => $subject], $data);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
$channelData
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success
];
}
return $results;
}
/**
* Send domain expiration notification
*/
public function sendDomainExpirationAlert(array $domain, array $notificationChannels): array
{
$daysLeft = $this->calculateDaysLeft($domain['expiration_date']);
$message = $this->formatExpirationMessage($domain, $daysLeft);
$results = [];
foreach ($notificationChannels as $channel) {
$config = json_decode($channel['channel_config'], true);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
[
'domain' => $domain['domain_name'],
'domain_id' => $domain['id'],
'days_left' => $daysLeft,
'expiration_date' => $domain['expiration_date'],
'registrar' => $domain['registrar']
]
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success
];
}
return $results;
}
/**
* Send domain status change notification via external channels
*/
public function sendDomainStatusAlert(array $domain, array $notificationChannels, string $newStatus, string $oldStatus): array
{
$message = $this->formatStatusChangeMessage($domain, $newStatus, $oldStatus);
$results = [];
foreach ($notificationChannels as $channel) {
$config = json_decode($channel['channel_config'], true);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
[
'domain' => $domain['domain_name'],
'domain_id' => $domain['id'],
'new_status' => $newStatus,
'old_status' => $oldStatus,
'registrar' => $domain['registrar'] ?? 'Unknown'
]
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success
];
}
return $results;
}
/**
* Format status change notification message
*/
private function formatStatusChangeMessage(array $domain, string $newStatus, string $oldStatus): string
{
$domainName = $domain['domain_name'];
$registrar = $domain['registrar'] ?? 'Unknown';
$oldStatusLabel = self::getStatusLabel($oldStatus);
$newStatusLabel = self::getStatusLabel($newStatus);
return match($newStatus) {
'available' => "🟢 AVAILABLE: Domain '$domainName' is now available for registration!\n\n" .
"Previous status: $oldStatusLabel\n" .
"This domain can now be registered.",
'active' => "✅ REGISTERED: Domain '$domainName' is now registered and active.\n\n" .
"Previous status: $oldStatusLabel\n" .
"Registrar: $registrar",
'expired' => "🚨 EXPIRED: Domain '$domainName' has expired!\n\n" .
"Previous status: $oldStatusLabel\n" .
"Registrar: $registrar\n" .
"Please renew immediately to avoid losing your domain.",
'redemption_period' => "⚠️ REDEMPTION PERIOD: Domain '$domainName' has entered the redemption period!\n\n" .
"Previous status: $oldStatusLabel\n" .
"Registrar: $registrar\n" .
"The domain can still be recovered, but additional fees may apply. Act quickly!",
'pending_delete' => "🔴 PENDING DELETE: Domain '$domainName' is scheduled for deletion!\n\n" .
"Previous status: $oldStatusLabel\n" .
"Registrar: $registrar\n" .
"The domain will be released for public registration soon.",
default => " STATUS CHANGE: Domain '$domainName' status changed from $oldStatusLabel to $newStatusLabel.\n\n" .
"Registrar: $registrar"
};
}
/**
* Get human-readable status label
*/
public static function getStatusLabel(string $status): string
{
return match($status) {
'active' => 'Active',
'expiring_soon' => 'Expiring Soon',
'expired' => 'Expired',
'available' => 'Available',
'redemption_period' => 'Redemption Period',
'pending_delete' => 'Pending Delete',
'error' => 'Error',
default => ucfirst(str_replace('_', ' ', $status))
};
}
/**
* Format expiration message
*/
private function formatExpirationMessage(array $domain, int $daysLeft): string
{
$domainName = $domain['domain_name'];
$expirationDate = $domain['expiration_date'] ? date('F j, Y', strtotime($domain['expiration_date'])) : 'Unknown';
$registrar = $domain['registrar'] ?? 'Unknown';
if ($daysLeft <= 0) {
return "🚨 URGENT: Domain '$domainName' has EXPIRED on $expirationDate!\n\n" .
"Registrar: $registrar\n" .
"Please renew immediately to avoid losing your domain.";
}
if ($daysLeft == 1) {
return "⚠️ CRITICAL: Domain '$domainName' expires TOMORROW ($expirationDate)!\n\n" .
"Registrar: $registrar\n" .
"Please renew as soon as possible.";
}
if ($daysLeft <= 7) {
return "⚠️ WARNING: Domain '$domainName' expires in $daysLeft days ($expirationDate)!\n\n" .
"Registrar: $registrar\n" .
"Please renew soon.";
}
return " REMINDER: Domain '$domainName' expires in $daysLeft days ($expirationDate).\n\n" .
"Registrar: $registrar\n" .
"Please plan for renewal.";
}
/**
* Calculate days left until expiration
*/
private function calculateDaysLeft(string $expirationDate): int
{
$expiration = strtotime($expirationDate);
$now = time();
return (int)floor(($expiration - $now) / 86400);
}
// ========================================
// IN-APP NOTIFICATION METHODS (Bell Icon)
// ========================================
/**
* Create a domain expiring notification (in-app)
*/
public function notifyDomainExpiring(int $userId, string $domainName, int $daysLeft, int $domainId): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_expiring',
'Domain Expiring Soon',
"{$domainName} expires in {$daysLeft} day" . ($daysLeft > 1 ? 's' : ''),
$domainId
);
}
/**
* Create a domain expired notification (in-app)
*/
public function notifyDomainExpired(int $userId, string $domainName, int $domainId): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_expired',
'Domain Expired',
"{$domainName} has expired - renew immediately",
$domainId
);
}
/**
* Create a domain available notification (in-app)
*/
public function notifyDomainAvailable(int $userId, string $domainName, int $domainId): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_available',
'Domain Available',
"{$domainName} is now available for registration",
$domainId
);
}
/**
* Create a domain registered notification (in-app)
* Triggered when a domain transitions from available/expired/pending_delete to active
*/
public function notifyDomainRegistered(int $userId, string $domainName, int $domainId): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_registered',
'Domain Registered',
"{$domainName} has been registered and is now active",
$domainId
);
}
/**
* Create a domain redemption period notification (in-app)
*/
public function notifyDomainRedemption(int $userId, string $domainName, int $domainId): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_redemption',
'Domain in Redemption Period',
"{$domainName} has entered the redemption period - recovery fees may apply",
$domainId
);
}
/**
* Create a domain pending delete notification (in-app)
*/
public function notifyDomainPendingDelete(int $userId, string $domainName, int $domainId): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_pending_delete',
'Domain Pending Deletion',
"{$domainName} is scheduled for deletion and will be available soon",
$domainId
);
}
/**
* Create a domain WHOIS updated notification (in-app)
*/
public function notifyDomainUpdated(int $userId, string $domainName, int $domainId, string $changes = ''): void
{
$notificationModel = new \App\Models\Notification();
$message = !empty($changes) ?
"{$domainName} - {$changes}" :
"{$domainName} WHOIS data updated";
$notificationModel->createNotification(
$userId,
'domain_updated',
'Domain WHOIS Updated',
$message,
$domainId
);
}
/**
* Create a WHOIS lookup failed notification (in-app)
*/
public function notifyWhoisFailed(int $userId, string $domainName, int $domainId, string $reason = ''): void
{
$notificationModel = new \App\Models\Notification();
$message = !empty($reason) ?
"Could not refresh {$domainName} - {$reason}" :
"Could not refresh {$domainName}";
$notificationModel->createNotification(
$userId,
'whois_failed',
'WHOIS Lookup Failed',
$message,
$domainId
);
}
/**
* Create a new login notification (in-app) with rich geolocation data
*/
public function notifyNewLogin(int $userId, string $method, string $ipAddress, ?string $userAgent = null): void
{
// Get geolocation data
$geo = \App\Models\SessionManager::getGeolocationData($ipAddress);
// Parse browser/device from user agent
$browser = 'Unknown Browser';
$device = 'Desktop';
$deviceIcon = 'desktop';
if ($userAgent) {
$ua = strtolower($userAgent);
// Browser detection
if (strpos($ua, 'edg') !== false) {
$browser = 'Edge';
} elseif (strpos($ua, 'opr') !== false || strpos($ua, 'opera') !== false) {
$browser = 'Opera';
} elseif (strpos($ua, 'chrome') !== false) {
$browser = 'Chrome';
} elseif (strpos($ua, 'safari') !== false) {
$browser = 'Safari';
} elseif (strpos($ua, 'firefox') !== false) {
$browser = 'Firefox';
}
// Device detection
if (strpos($ua, 'mobile') !== false || strpos($ua, 'android') !== false || strpos($ua, 'iphone') !== false) {
$device = 'Mobile';
$deviceIcon = 'mobile-alt';
} elseif (strpos($ua, 'tablet') !== false || strpos($ua, 'ipad') !== false) {
$device = 'Tablet';
$deviceIcon = 'tablet-alt';
}
// OS detection
$os = 'Unknown';
if (strpos($ua, 'windows') !== false) $os = 'Windows';
elseif (strpos($ua, 'macintosh') !== false || strpos($ua, 'mac os') !== false) $os = 'macOS';
elseif (strpos($ua, 'linux') !== false) $os = 'Linux';
elseif (strpos($ua, 'android') !== false) $os = 'Android';
elseif (strpos($ua, 'iphone') !== false || strpos($ua, 'ipad') !== false) $os = 'iOS';
}
// Build location string
$locationParts = [];
if ($geo['city'] !== 'Unknown' && $geo['city'] !== 'Local') {
$locationParts[] = $geo['city'];
}
if ($geo['country'] !== 'Unknown' && $geo['country'] !== 'Local') {
$locationParts[] = $geo['country'];
}
$locationStr = !empty($locationParts) ? implode(', ', $locationParts) : 'Unknown location';
// Store rich data as JSON in message field
$messageData = json_encode([
'method' => $method,
'ip' => $ipAddress,
'country' => $geo['country'],
'country_code' => $geo['country_code'],
'city' => $geo['city'],
'region' => $geo['region'],
'isp' => $geo['isp'],
'browser' => $browser,
'device' => $device,
'device_icon' => $deviceIcon,
'os' => $os ?? 'Unknown',
'location' => $locationStr,
]);
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'session_new',
'New Login Detected',
$messageData,
null
);
}
/**
* Create a failed login notification (in-app) with rich geolocation data
*/
public function notifyFailedLogin(int $userId, string $reason, string $ipAddress, ?string $userAgent = null, ?string $attemptedUsername = null): void
{
// Get geolocation data
$geo = \App\Models\SessionManager::getGeolocationData($ipAddress);
// Parse browser/device from user agent
$browser = 'Unknown Browser';
$device = 'Desktop';
$deviceIcon = 'desktop';
if ($userAgent) {
$ua = strtolower($userAgent);
// Browser detection
if (strpos($ua, 'edg') !== false) {
$browser = 'Edge';
} elseif (strpos($ua, 'opr') !== false || strpos($ua, 'opera') !== false) {
$browser = 'Opera';
} elseif (strpos($ua, 'chrome') !== false) {
$browser = 'Chrome';
} elseif (strpos($ua, 'safari') !== false) {
$browser = 'Safari';
} elseif (strpos($ua, 'firefox') !== false) {
$browser = 'Firefox';
}
// Device detection
if (strpos($ua, 'mobile') !== false || strpos($ua, 'android') !== false || strpos($ua, 'iphone') !== false) {
$device = 'Mobile';
$deviceIcon = 'mobile-alt';
} elseif (strpos($ua, 'tablet') !== false || strpos($ua, 'ipad') !== false) {
$device = 'Tablet';
$deviceIcon = 'tablet-alt';
}
// OS detection
$os = 'Unknown';
if (strpos($ua, 'windows') !== false) $os = 'Windows';
elseif (strpos($ua, 'macintosh') !== false || strpos($ua, 'mac os') !== false) $os = 'macOS';
elseif (strpos($ua, 'linux') !== false) $os = 'Linux';
elseif (strpos($ua, 'android') !== false) $os = 'Android';
elseif (strpos($ua, 'iphone') !== false || strpos($ua, 'ipad') !== false) $os = 'iOS';
}
// Build location string
$locationParts = [];
if ($geo['city'] !== 'Unknown' && $geo['city'] !== 'Local') {
$locationParts[] = $geo['city'];
}
if ($geo['country'] !== 'Unknown' && $geo['country'] !== 'Local') {
$locationParts[] = $geo['country'];
}
$locationStr = !empty($locationParts) ? implode(', ', $locationParts) : 'Unknown location';
// Store rich data as JSON in message field
$messageData = json_encode([
'reason' => $reason,
'attempted_username' => $attemptedUsername,
'ip' => $ipAddress,
'country' => $geo['country'],
'country_code' => $geo['country_code'],
'city' => $geo['city'],
'region' => $geo['region'],
'isp' => $geo['isp'],
'browser' => $browser,
'device' => $device,
'device_icon' => $deviceIcon,
'os' => $os ?? 'Unknown',
'location' => $locationStr,
]);
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'session_failed',
'Failed Login Attempt',
$messageData,
null
);
}
// Future improvement: Add notifyAdminsFailedLogin() to send in-app alerts to all admins on failed login attempts (e.g. unknown usernames, brute-force detection)
/**
* Create welcome notification for new users/fresh install (in-app)
*/
public function notifyWelcome(int $userId, string $username): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'system_welcome',
'Welcome to Domain Monitor! 🎉',
"Hi {$username}! Your account is ready. Start by adding your first domain to monitor.",
null
);
}
/**
* Create system upgrade notification for admins (in-app)
* @param bool $composerManualRequired If true, appends a note to run composer install manually (e.g. when exec is disabled on cPanel)
*/
public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void
{
$migrationLabel = $migrationsCount . ' migration' . ($migrationsCount !== 1 ? 's' : '') . ' applied';
// Detect if $toVersion is a commit SHA (7-40 hex chars) rather than a semver string
$isCommitSha = (bool) preg_match('/^[a-f0-9]{7,40}$/i', $toVersion);
if ($isCommitSha) {
// Hotfix: file-only update identified by commit SHA
$message = "Domain Monitor v{$fromVersion} has been updated (hotfix {$toVersion}, {$migrationLabel})";
} elseif ($fromVersion === $toVersion) {
// Hotfix: same version, just file updates
$message = "Domain Monitor v{$toVersion} has been updated ({$migrationLabel})";
} else {
$message = "Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationLabel})";
}
if ($composerManualRequired) {
$message .= ". Composer could not be run here (e.g. exec disabled). If dependencies changed, run \"composer install --no-dev\" manually via SSH or Terminal.";
}
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'system_upgrade',
'System Upgraded Successfully',
$message,
null
);
}
/**
* Notify all admins about system upgrade (in-app)
* @param bool $composerManualRequired If true, in-app message will include a note to run composer install manually
*/
public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void
{
try {
$userModel = new \App\Models\User();
$admins = $userModel->getAllAdmins();
foreach ($admins as $admin) {
$this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount, $composerManualRequired);
}
} catch (\Exception $e) {
$logger = new \App\Services\Logger();
$logger->error("Failed to notify admins about upgrade", [
'error' => $e->getMessage()
]);
}
}
/**
* Create "update available" in-app notification for one user
*/
public function notifyUpdateAvailable(int $userId, string $currentVersion, string $latestVersion, string $type = 'release', ?int $commitsBehind = null): void
{
$notificationModel = new \App\Models\Notification();
$title = 'Update Available';
if ($type === 'release') {
$message = "A new version of Domain Monitor is available: v{$latestVersion} (you have v{$currentVersion}). Go to Settings → Updates to apply.";
} else {
$msg = $commitsBehind
? "{$commitsBehind} new commit(s) are available on the main branch. Go to Settings → Updates to apply the hotfix."
: "New commits are available. Go to Settings → Updates to apply the hotfix.";
$message = $msg;
}
$notificationModel->createNotification(
$userId,
'update_available',
$title,
$message,
null
);
}
/**
* Notify all admins that an update is available (in-app)
* Used by cron when it detects a new version or hotfix.
*/
public function notifyAdminsUpdateAvailable(string $currentVersion, string $latestVersionOrLabel, string $type = 'release', ?int $commitsBehind = null): void
{
try {
$userModel = new \App\Models\User();
$admins = $userModel->getAllAdmins();
foreach ($admins as $admin) {
$this->notifyUpdateAvailable($admin['id'], $currentVersion, $latestVersionOrLabel, $type, $commitsBehind);
}
} catch (\Exception $e) {
$logger = new \App\Services\Logger();
$logger->error("Failed to notify admins about available update", [
'error' => $e->getMessage()
]);
}
}
// ========================================
// DNS MONITORING NOTIFICATIONS
// ========================================
/**
* Create a DNS change notification (in-app / bell icon)
*/
public function notifyDnsChange(int $userId, string $domainName, int $domainId, string $summary): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'dns_change',
'DNS Records Changed',
"{$domainName} - {$summary}",
$domainId
);
}
/**
* Send DNS change alert to external channels
*/
public function sendDnsChangeAlert(array $domain, array $notificationChannels, string $detailMessage): array
{
$results = [];
foreach ($notificationChannels as $channel) {
$config = json_decode($channel['channel_config'], true);
$success = $this->send(
$channel['channel_type'],
$config,
$detailMessage,
[
'subject' => "DNS Changes: {$domain['domain_name']}",
'domain' => $domain['domain_name'],
'domain_id' => $domain['id'],
]
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success,
];
}
return $results;
}
/**
* Create an SSL monitoring notification (in-app / bell icon).
*/
public function notifySslStatusChange(
int $userId,
string $domainName,
string $hostname,
int $domainId,
string $newStatus,
?string $oldStatus = null
): void {
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'ssl_status_change',
$this->getSslNotificationTitle($newStatus),
$this->formatSslStatusSummary($domainName, $hostname, $newStatus, $oldStatus),
$domainId
);
}
/**
* Send SSL status alert to external channels.
*/
public function sendSslStatusAlert(
array $domain,
array $notificationChannels,
string $hostname,
string $newStatus,
?string $oldStatus = null,
?string $validTo = null,
?string $error = null
): array {
$message = $this->formatSslStatusMessage($domain, $hostname, $newStatus, $oldStatus, $validTo, $error);
$results = [];
foreach ($notificationChannels as $channel) {
$config = json_decode($channel['channel_config'], true);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
[
'subject' => $this->getSslNotificationTitle($newStatus) . ': ' . $hostname,
'domain' => $domain['domain_name'],
'domain_id' => $domain['id'],
'hostname' => $hostname,
'new_status' => $newStatus,
'old_status' => $oldStatus,
'valid_to' => $validTo,
'error' => $error,
]
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success,
];
}
return $results;
}
/**
* Get SSL status label for human-readable messages.
*/
public static function getSslStatusLabel(string $status): string
{
return match ($status) {
'valid' => 'Valid',
'expiring' => 'Expiring Soon',
'expired' => 'Expired',
'invalid' => 'Invalid',
default => ucfirst(str_replace('_', ' ', $status)),
};
}
private function getSslNotificationTitle(string $status): string
{
return match ($status) {
'valid' => 'SSL Certificate Recovered',
'expiring' => 'SSL Certificate Expiring Soon',
'expired' => 'SSL Certificate Expired',
'invalid' => 'SSL Certificate Check Failed',
default => 'SSL Status Changed',
};
}
private function formatSslStatusSummary(
string $domainName,
string $hostname,
string $newStatus,
?string $oldStatus = null
): string {
$hostText = $hostname === $domainName ? $domainName : "{$hostname} ({$domainName})";
$newLabel = self::getSslStatusLabel($newStatus);
if ($oldStatus !== null) {
$oldLabel = self::getSslStatusLabel($oldStatus);
return "{$hostText} - SSL status changed from {$oldLabel} to {$newLabel}";
}
return "{$hostText} - SSL status is {$newLabel}";
}
private function formatSslStatusMessage(
array $domain,
string $hostname,
string $newStatus,
?string $oldStatus = null,
?string $validTo = null,
?string $error = null
): string {
$domainName = $domain['domain_name'];
$validToText = $validTo ? date('F j, Y H:i', strtotime($validTo)) : 'Unknown';
$oldLabel = $oldStatus !== null ? self::getSslStatusLabel($oldStatus) : null;
return match ($newStatus) {
'valid' => "✅ SSL RECOVERED: {$hostname} is now using a valid certificate.\n\n" .
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
"Domain: {$domainName}\n" .
"Valid until: {$validToText}",
'expiring' => "⚠️ SSL EXPIRING SOON: {$hostname} is approaching certificate expiration.\n\n" .
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
"Domain: {$domainName}\n" .
"Valid until: {$validToText}",
'expired' => "🚨 SSL EXPIRED: {$hostname} has an expired certificate.\n\n" .
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
"Domain: {$domainName}\n" .
"Expired on: {$validToText}",
'invalid' => "❌ SSL INVALID: {$hostname} failed certificate validation.\n\n" .
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
"Domain: {$domainName}\n" .
($error ? "Error: {$error}\n" : ''),
default => " SSL STATUS CHANGE: {$hostname} changed SSL status.\n\n" .
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
"Current status: " . self::getSslStatusLabel($newStatus) . "\n" .
"Domain: {$domainName}"
};
}
/**
* Delete old read notifications (cleanup)
*/
public function cleanOldNotifications(int $daysOld = 30): void
{
try {
$pdo = \Core\Database::getConnection();
$stmt = $pdo->prepare(
"DELETE FROM user_notifications
WHERE is_read = 1
AND read_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
);
$stmt->execute([$daysOld]);
} catch (\Exception $e) {
$logger = new \App\Services\Logger();
$logger->error("Failed to clean old notifications", [
'error' => $e->getMessage()
]);
}
}
}