Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php
T
Jonathan Miller 9656a2a92b
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Generic: Project CI / Lint & Validate (push) Successful in 11s
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 10s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Project CI / Lint & Validate (pull_request) Successful in 35s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 38s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
fix: PR #46 review — error handling, failure notifications, cleanup
Critical:
- Wrap cleanupOldBackups() in try-catch to prevent admin panel crash
- Add missing fields (total_size, files_count, etc.) to failure record
  so failure notifications actually send

High:
- Log unlink failures in deleteBackupRecord() instead of silent return
- Wrap DB delete in try-catch so one failed record doesn't abort loop
- Check for ext-curl before calling curl_init() in sendNtfy()

Medium:
- Change runPreActionBackup catch from \Exception to \Throwable
- Log warning for skipped files during archive encryption
- Truncate ntfy response body in error logs (200 chars max)
2026-06-18 10:26:48 -05:00

278 lines
7.9 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;
}
}
/**
* 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 [];
}
}
}