391047d8e5
Send notifications when site restores and snapshot create/restore complete. Uses sendRestoreNotification() with type-specific subjects. All calls wrapped in try-catch to never break the actual operation. Closes #60
569 lines
17 KiB
PHP
569 lines
17 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*
|
|
* Sends email notifications on backup success or failure.
|
|
* Uses Joomla's built-in mail system (Factory::getMailer()).
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Language\Text;
|
|
use Joomla\CMS\Uri\Uri;
|
|
|
|
class NotificationSender
|
|
{
|
|
/**
|
|
* Send a backup notification email.
|
|
*
|
|
* @param object $profile Profile object with notification settings
|
|
* @param object $record Backup record object with results
|
|
* @param bool $success Whether the backup succeeded
|
|
* @param string $logText Backup log text
|
|
*
|
|
* @return bool True if email was sent
|
|
*/
|
|
public static function send(object $profile, object $record, bool $success, string $logText = ''): bool
|
|
{
|
|
$emailSent = self::sendEmail($profile, $record, $success, $logText);
|
|
$ntfySent = self::sendNtfy($profile, $record, $success);
|
|
|
|
return $emailSent || $ntfySent;
|
|
}
|
|
|
|
private static function sendEmail(object $profile, object $record, bool $success, string $logText = ''): bool
|
|
{
|
|
$notifyEmail = trim($profile->notify_email ?? '');
|
|
$notifyUserGroups = $profile->notify_user_groups ?? '';
|
|
|
|
// Resolve user group members to email addresses
|
|
$groupEmails = self::resolveUserGroupEmails($notifyUserGroups);
|
|
|
|
if (empty($notifyEmail) && empty($groupEmails)) {
|
|
return false;
|
|
}
|
|
|
|
// Check notification preferences
|
|
if ($success && empty($profile->notify_on_success)) {
|
|
return false;
|
|
}
|
|
|
|
if (!$success && empty($profile->notify_on_failure)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$mailer = Factory::getMailer();
|
|
$config = Factory::getApplication()->getConfig();
|
|
$siteName = $config->get('sitename', 'Joomla Site');
|
|
$siteUrl = Uri::root();
|
|
|
|
// Parse recipient list (comma-separated) + user group emails
|
|
$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);
|
|
}
|
|
|
|
// Build subject
|
|
$statusLabel = $success ? 'SUCCESS' : 'FAILED';
|
|
$mailer->setSubject("[MokoSuiteBackup] {$statusLabel}: {$record->description} — {$siteName}");
|
|
|
|
// Build body
|
|
$duration = '';
|
|
|
|
if (!empty($record->backupstart) && !empty($record->backupend)
|
|
&& $record->backupend !== '0000-00-00 00:00:00') {
|
|
$start = strtotime($record->backupstart);
|
|
$end = strtotime($record->backupend);
|
|
$seconds = max(0, $end - $start);
|
|
$duration = $seconds < 60
|
|
? $seconds . ' seconds'
|
|
: round($seconds / 60, 1) . ' minutes';
|
|
}
|
|
|
|
$sizeHuman = $record->total_size > 0
|
|
? number_format($record->total_size / 1048576, 2) . ' MB'
|
|
: 'N/A';
|
|
|
|
$body = "MokoSuiteBackup Notification\n"
|
|
. "============================\n\n"
|
|
. "Site: {$siteName}\n"
|
|
. "URL: {$siteUrl}\n"
|
|
. "Status: {$statusLabel}\n"
|
|
. "Profile: {$profile->title}\n"
|
|
. "Description: {$record->description}\n"
|
|
. "Type: {$record->backup_type}\n"
|
|
. "Origin: {$record->origin}\n"
|
|
. "Archive: {$record->archivename}\n"
|
|
. "Size: {$sizeHuman}\n"
|
|
. "Duration: {$duration}\n"
|
|
. "Started: {$record->backupstart}\n"
|
|
. "Ended: {$record->backupend}\n";
|
|
|
|
if (!empty($record->remote_filename)) {
|
|
$body .= "Remote: {$record->remote_filename}\n";
|
|
}
|
|
|
|
if ($record->files_count > 0 || $record->tables_count > 0) {
|
|
$body .= "Files: {$record->files_count}\n"
|
|
. "Tables: {$record->tables_count}\n";
|
|
}
|
|
|
|
// Add log excerpt on failure (last 30 lines)
|
|
if (!$success && !empty($logText)) {
|
|
$logLines = explode("\n", $logText);
|
|
$excerpt = array_slice($logLines, -30);
|
|
$body .= "\n--- Log (last 30 lines) ---\n"
|
|
. implode("\n", $excerpt) . "\n";
|
|
}
|
|
|
|
$body .= "\n--\n"
|
|
. "MokoSuiteBackup — https://mokoconsulting.tech\n";
|
|
|
|
$mailer->setBody($body);
|
|
$mailer->isHtml(false);
|
|
|
|
return $mailer->Send();
|
|
} catch (\Throwable $e) {
|
|
// Don't let notification failure break the backup flow
|
|
error_log('MokoSuiteBackup notification error: ' . $e->getMessage());
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a push notification via ntfy.
|
|
*/
|
|
private static function sendNtfy(object $profile, object $record, bool $success): bool
|
|
{
|
|
$topic = trim($profile->ntfy_topic ?? '');
|
|
$server = trim($profile->ntfy_server ?? 'https://ntfy.sh');
|
|
$token = trim($profile->ntfy_token ?? '');
|
|
|
|
if ($topic === '') {
|
|
return false;
|
|
}
|
|
|
|
// Respect the same success/failure preferences as email
|
|
if ($success && empty($profile->notify_on_success)) {
|
|
return false;
|
|
}
|
|
|
|
if (!$success && empty($profile->notify_on_failure)) {
|
|
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');
|
|
|
|
$statusLabel = $success ? 'SUCCESS' : 'FAILED';
|
|
$statusEmoji = $success ? "\xE2\x9C\x85" : "\xE2\x9D\x8C";
|
|
|
|
$sizeHuman = $record->total_size > 0
|
|
? number_format($record->total_size / 1048576, 2) . ' MB'
|
|
: 'N/A';
|
|
|
|
$title = "{$statusEmoji} Backup {$statusLabel}: {$siteName}";
|
|
$body = "Profile: {$profile->title}\n"
|
|
. "Type: {$record->backup_type}\n"
|
|
. "Archive: {$record->archivename}\n"
|
|
. "Size: {$sizeHuman}";
|
|
|
|
$url = rtrim($server, '/') . '/' . rawurlencode($topic);
|
|
|
|
$headers = [
|
|
'Title: ' . $title,
|
|
'Priority: ' . ($success ? '3' : '5'),
|
|
'Tags: ' . ($success ? 'white_check_mark' : 'rotating_light'),
|
|
];
|
|
|
|
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 notification error: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param string|array $groups Comma-separated group IDs or array
|
|
*
|
|
* @return array Email addresses
|
|
*/
|
|
private static function resolveUserGroupEmails(string|array $groups): array
|
|
{
|
|
if (empty($groups)) {
|
|
return [];
|
|
}
|
|
|
|
if (\is_string($groups)) {
|
|
$groups = array_filter(array_map('intval', explode(',', $groups)));
|
|
}
|
|
|
|
if (empty($groups)) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('DISTINCT ' . $db->quoteName('u.email'))
|
|
->from($db->quoteName('#__users', 'u'))
|
|
->join('INNER', $db->quoteName('#__user_usergroup_map', 'ugm') . ' ON ugm.user_id = u.id')
|
|
->where($db->quoteName('u.block') . ' = 0')
|
|
->whereIn($db->quoteName('ugm.group_id'), $groups);
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadColumn() ?: [];
|
|
} catch (\Throwable $e) {
|
|
error_log('MokoSuiteBackup: Could not resolve user group emails: ' . $e->getMessage());
|
|
|
|
return [];
|
|
}
|
|
}
|
|
}
|