diff --git a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php index a5631fd7..a214c7a3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php +++ b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php @@ -236,6 +236,297 @@ class NotificationSender } } + /** + * Send a restore/snapshot notification via email and ntfy. + * + * @param object $profile Profile object with notification settings + * @param string $type One of: site_restore, snapshot_create, snapshot_restore + * @param array $details Context: record_id, content_types, row_count, mode, user, etc. + * @param string $log Operation log text + * + * @return bool True if at least one notification was sent + */ + public static function sendRestoreNotification(object $profile, string $type, array $details, string $log = ''): bool + { + $emailSent = self::sendRestoreEmail($profile, $type, $details, $log); + $ntfySent = self::sendRestoreNtfy($profile, $type, $details); + + return $emailSent || $ntfySent; + } + + /** + * Load the default profile (ID 1) for notification settings. + * + * @return object|null Profile object or null if not found + */ + public static function getDefaultProfile(): ?object + { + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = 1'); + $db->setQuery($query); + + return $db->loadObject() ?: null; + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Cannot load default profile: ' . $e->getMessage()); + + return null; + } + } + + /** + * Build subject and body for a restore/snapshot notification email. + */ + private static function buildRestoreMessage(string $type, array $details, string $siteName, string $siteUrl): array + { + $user = $details['user'] ?? 'Unknown'; + + switch ($type) { + case 'site_restore': + $subject = "[MokoSuiteBackup] RESTORE: Site restored — {$siteName}"; + $options = []; + + if (!empty($details['restore_files'])) { + $options[] = 'Files'; + } + + if (!empty($details['restore_db'])) { + $options[] = 'Database'; + } + + if (!empty($details['preserve_config'])) { + $options[] = 'Config preserved'; + } + + $body = "MokoSuiteBackup — Site Restore Notification\n" + . "=============================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Full site restore\n" + . "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" + . "Options: " . (empty($options) ? 'N/A' : implode(', ', $options)) . "\n" + . "Triggered by: {$user}\n"; + break; + + case 'snapshot_create': + $types = $details['content_types'] ?? []; + $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; + + $subject = "[MokoSuiteBackup] SNAPSHOT: Content snapshot created — {$siteName}"; + $body = "MokoSuiteBackup — Snapshot Created\n" + . "===================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Snapshot created\n" + . "Content types: {$typesStr}\n" + . "Articles: " . ($details['articles_count'] ?? 0) . "\n" + . "Categories: " . ($details['categories_count'] ?? 0) . "\n" + . "Modules: " . ($details['modules_count'] ?? 0) . "\n" + . "Triggered by: {$user}\n"; + break; + + case 'snapshot_restore': + $types = $details['content_types'] ?? []; + $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; + + $subject = "[MokoSuiteBackup] RESTORE: Snapshot restored — {$siteName}"; + $body = "MokoSuiteBackup — Snapshot Restore Notification\n" + . "================================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Snapshot restore\n" + . "Mode: " . ($details['mode'] ?? 'N/A') . "\n" + . "Content types: {$typesStr}\n" + . "Rows restored: " . ($details['row_count'] ?? 0) . "\n" + . "Triggered by: {$user}\n"; + break; + + default: + $subject = "[MokoSuiteBackup] NOTIFICATION: {$type} — {$siteName}"; + $body = "MokoSuiteBackup Notification\n" + . "============================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Type: {$type}\n" + . "Details: " . json_encode($details) . "\n"; + break; + } + + $body .= "\n--\n" + . "MokoSuiteBackup — https://mokoconsulting.tech\n"; + + return ['subject' => $subject, 'body' => $body]; + } + + /** + * Send a restore/snapshot notification email. + */ + private static function sendRestoreEmail(object $profile, string $type, array $details, string $log = ''): bool + { + $notifyEmail = trim($profile->notify_email ?? ''); + $notifyUserGroups = $profile->notify_user_groups ?? ''; + + $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); + + if (empty($notifyEmail) && empty($groupEmails)) { + return false; + } + + // Restore notifications are always "success" events — use notify_on_success preference + if (empty($profile->notify_on_success)) { + return false; + } + + try { + $mailer = Factory::getMailer(); + $config = Factory::getApplication()->getConfig(); + $siteName = $config->get('sitename', 'Joomla Site'); + $siteUrl = Uri::root(); + + $recipients = array_map('trim', explode(',', $notifyEmail)); + $recipients = array_merge($recipients, $groupEmails); + $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); + + if (empty($recipients)) { + return false; + } + + foreach ($recipients as $recipient) { + $mailer->addRecipient($recipient); + } + + $message = self::buildRestoreMessage($type, $details, $siteName, $siteUrl); + $mailer->setSubject($message['subject']); + + $body = $message['body']; + + // Append log excerpt if provided (last 30 lines) + if (!empty($log)) { + $logLines = explode("\n", $log); + $excerpt = array_slice($logLines, -30); + $body .= "\n--- Log (last 30 lines) ---\n" + . implode("\n", $excerpt) . "\n"; + } + + $mailer->setBody($body); + $mailer->isHtml(false); + + return $mailer->Send(); + } catch (\Throwable $e) { + error_log('MokoSuiteBackup restore notification error: ' . $e->getMessage()); + + return false; + } + } + + /** + * Send a restore/snapshot push notification via ntfy. + */ + private static function sendRestoreNtfy(object $profile, string $type, array $details): bool + { + $topic = trim($profile->ntfy_topic ?? ''); + $server = trim($profile->ntfy_server ?? 'https://ntfy.sh'); + $token = trim($profile->ntfy_token ?? ''); + + if ($topic === '') { + return false; + } + + // Restore notifications are always "success" events — use notify_on_success preference + if (empty($profile->notify_on_success)) { + return false; + } + + if (!function_exists('curl_init')) { + error_log('MokoSuiteBackup: ntfy notifications require ext-curl'); + + return false; + } + + try { + $config = Factory::getApplication()->getConfig(); + $siteName = $config->get('sitename', 'Joomla Site'); + + switch ($type) { + case 'site_restore': + $emoji = "\xF0\x9F\x94\x84"; // 🔄 + $title = "{$emoji} Site Restored: {$siteName}"; + $body = "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" + . "Triggered by: " . ($details['user'] ?? 'Unknown'); + break; + + case 'snapshot_create': + $emoji = "\xF0\x9F\x93\xB8"; // 📸 + $types = $details['content_types'] ?? []; + $title = "{$emoji} Snapshot Created: {$siteName}"; + $body = "Types: " . implode(', ', $types) . "\n" + . "Articles: " . ($details['articles_count'] ?? 0) . "\n" + . "Categories: " . ($details['categories_count'] ?? 0) . "\n" + . "Modules: " . ($details['modules_count'] ?? 0); + break; + + case 'snapshot_restore': + $emoji = "\xF0\x9F\x94\x84"; // 🔄 + $types = $details['content_types'] ?? []; + $title = "{$emoji} Snapshot Restored: {$siteName}"; + $body = "Mode: " . ($details['mode'] ?? 'N/A') . "\n" + . "Types: " . implode(', ', $types) . "\n" + . "Rows: " . ($details['row_count'] ?? 0); + break; + + default: + $title = "MokoSuiteBackup: {$type} — {$siteName}"; + $body = json_encode($details); + break; + } + + $url = rtrim($server, '/') . '/' . rawurlencode($topic); + + $headers = [ + 'Title: ' . $title, + 'Priority: 3', + 'Tags: arrows_counterclockwise', + ]; + + if ($token !== '') { + $headers[] = 'Authorization: Bearer ' . $token; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error !== '') { + error_log('MokoSuiteBackup: ntfy error: ' . $error); + return false; + } + + if ($httpCode < 200 || $httpCode >= 300) { + error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200)); + return false; + } + + return true; + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: ntfy restore notification error: ' . $e->getMessage()); + return false; + } + } + /** * Resolve user group IDs to email addresses of group members. * diff --git a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php index 7ffd2b6a..29a3d0c9 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php @@ -146,6 +146,26 @@ class RestoreEngine $this->log('Restore complete'); + // Send restore notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userId = Factory::getApplication()->getIdentity()->id ?? 0; + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + + NotificationSender::sendRestoreNotification($profile, 'site_restore', [ + 'record_id' => $recordId, + 'restore_files' => $restoreFiles, + 'restore_db' => $restoreDb, + 'preserve_config' => $preserveConfig, + 'user' => $userName . ' (ID: ' . $userId . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage()); + } + return [ 'success' => true, 'message' => 'Restore complete from: ' . basename($archivePath), diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php index b581e632..cb63a2ae 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -194,6 +194,26 @@ class SnapshotEngine $this->log('Snapshot record created: ID ' . $record->id); + // Send snapshot creation notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; + + NotificationSender::sendRestoreNotification($profile, 'snapshot_create', [ + 'content_types' => array_values($validTypes), + 'articles_count' => $counts['articles'], + 'categories_count' => $counts['categories'], + 'modules_count' => $counts['modules'], + 'user' => $userName . ' (ID: ' . $userIdVal . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage()); + } + return [ 'success' => true, 'message' => sprintf( diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 917d2768..329ecdbf 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -151,6 +151,25 @@ class SnapshotRestoreEngine $this->log('Restore complete: ' . $totalRows . ' total rows'); + // Send snapshot restore notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; + + NotificationSender::sendRestoreNotification($profile, 'snapshot_restore', [ + 'mode' => $mode, + 'content_types' => $restoreTypes, + 'row_count' => $totalRows, + 'user' => $userName . ' (ID: ' . $userIdVal . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage()); + } + return [ 'success' => true, 'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),