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
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)
278 lines
7.9 KiB
PHP
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 [];
|
|
}
|
|
}
|
|
}
|