From 75e72c248a5cd3aeba40d853c871f1934f14e318 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 4 Jun 2026 20:40:07 -0500 Subject: [PATCH] feat: tar.gz archives, table checkbox excludes, user group notifications (#32, #33, #34) Archive formats (#32): - ArchiverInterface abstraction with ZipArchiver and TarGzArchiver - BackupEngine uses archiver factory based on profile archive_format - tar.gz uses PharData (bundled with PHP, no extra extensions) - RestoreEngine detects and extracts tar.gz via PharData - AES-256 encryption skipped for non-ZIP formats with log warning Exclude fields (#33): - ExcludeListField: dynamic table with add/remove rows for dirs and files - DatabaseTablesField: auto-populated checkbox list of all site tables - Replaces textarea-based exclusion fields in profile form User group notifications (#34): - usergrouplist field added to profile notifications fieldset - NotificationSender resolves group members to emails at send time - Combined with manual email addresses, deduplicated - SQL migration adds notify_user_groups column Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/packages/com_mokobackup/forms/profile.xml | 26 ++-- .../language/en-GB/com_mokobackup.ini | 8 ++ .../language/en-US/com_mokobackup.ini | 7 + .../com_mokobackup/sql/install.mysql.sql | 1 + .../sql/updates/mysql/01.01.08.sql | 3 + .../src/Engine/ArchiverInterface.php | 41 ++++++ .../src/Engine/BackupEngine.php | 41 ++++-- .../src/Engine/NotificationSender.php | 50 +++++++- .../src/Engine/RestoreEngine.php | 15 ++- .../src/Engine/TarGzArchiver.php | 63 +++++++++ .../com_mokobackup/src/Engine/ZipArchiver.php | 47 +++++++ .../src/Field/DatabaseTablesField.php | 105 +++++++++++++++ .../src/Field/ExcludeListField.php | 120 ++++++++++++++++++ 13 files changed, 500 insertions(+), 27 deletions(-) create mode 100644 src/packages/com_mokobackup/src/Engine/ArchiverInterface.php create mode 100644 src/packages/com_mokobackup/src/Engine/TarGzArchiver.php create mode 100644 src/packages/com_mokobackup/src/Engine/ZipArchiver.php create mode 100644 src/packages/com_mokobackup/src/Field/DatabaseTablesField.php create mode 100644 src/packages/com_mokobackup/src/Field/ExcludeListField.php diff --git a/src/packages/com_mokobackup/forms/profile.xml b/src/packages/com_mokobackup/forms/profile.xml index 837464c3..827f6cd5 100644 --- a/src/packages/com_mokobackup/forms/profile.xml +++ b/src/packages/com_mokobackup/forms/profile.xml @@ -39,6 +39,7 @@ default="zip" > + @@ -176,6 +176,14 @@ maxlength="512" hint="admin@example.com, backup@example.com" /> + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +interface ArchiverInterface +{ + /** + * Open or create the archive at the given path. + */ + public function open(string $path): void; + + /** + * Add a string as a file inside the archive. + */ + public function addFromString(string $localName, string $contents): void; + + /** + * Add a file from disk into the archive. + */ + public function addFile(string $filePath, string $localName): void; + + /** + * Finalize and close the archive. + */ + public function close(): void; + + /** + * Return the file extension for this archive type (e.g. 'zip', 'tar.gz'). + */ + public function getExtension(): string; +} diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php index cf456cd3..1b239fd8 100644 --- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php +++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php @@ -71,7 +71,10 @@ class BackupEngine $now = date('Y-m-d H:i:s'); $tag = date('Ymd_His'); $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); - $archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; + $archiveFormat = $profile->archive_format ?? 'zip'; + $archiver = $this->createArchiver($archiveFormat); + $archiveExt = $archiver->getExtension(); + $archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.' . $archiveExt; if (empty($description)) { $description = $profile->title . ' — ' . $now; @@ -105,12 +108,8 @@ class BackupEngine $this->log('Backup started: ' . $description); $archivePath = $this->backupDir . '/' . $archiveName; - // Create ZIP archive - $zip = new \ZipArchive(); - - if ($zip->open($archivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - throw new \RuntimeException('Cannot create archive: ' . $archivePath); - } + // Create archive + $archiver->open($archivePath); $dbSize = 0; $filesCount = 0; @@ -121,7 +120,7 @@ class BackupEngine $this->log('Starting database dump...'); $dumper = new DatabaseDumper($excludeTables); $sqlDump = $dumper->dump(); - $zip->addFromString('database.sql', $sqlDump); + $archiver->addFromString('database.sql', $sqlDump); $dbSize = strlen($sqlDump); $tablesCount = $dumper->getTablesCount(); $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); @@ -157,7 +156,7 @@ class BackupEngine $fullPath = JPATH_ROOT . '/' . $relativePath; if (is_file($fullPath) && is_readable($fullPath)) { - $zip->addFile($fullPath, $relativePath); + $archiver->addFile($fullPath, $relativePath); } } @@ -170,15 +169,19 @@ class BackupEngine } } - $zip->close(); + $archiver->close(); // Step 1.5: Apply AES-256 encryption (if configured) $encryptionPassword = $profile->encryption_password ?? ''; if (!empty($encryptionPassword)) { - $this->log('Encrypting archive with AES-256...'); - $this->encryptArchive($archivePath, $encryptionPassword); - $this->log('Archive encrypted'); + if ($archiveFormat !== 'zip') { + $this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption'); + } else { + $this->log('Encrypting archive with AES-256...'); + $this->encryptArchive($archivePath, $encryptionPassword); + $this->log('Archive encrypted'); + } } // Record archive size and compute checksum (after encryption) @@ -361,6 +364,18 @@ class BackupEngine return true; } + /** + * Create the appropriate archiver based on the archive format. + */ + private function createArchiver(string $format): ArchiverInterface + { + return match ($format) { + 'zip' => new ZipArchiver(), + 'tar.gz' => new TarGzArchiver(), + default => new ZipArchiver(), + }; + } + /** * Create the appropriate remote uploader based on the storage type. */ diff --git a/src/packages/com_mokobackup/src/Engine/NotificationSender.php b/src/packages/com_mokobackup/src/Engine/NotificationSender.php index 82808a0b..9008aa29 100644 --- a/src/packages/com_mokobackup/src/Engine/NotificationSender.php +++ b/src/packages/com_mokobackup/src/Engine/NotificationSender.php @@ -33,9 +33,13 @@ class NotificationSender */ public static function send(object $profile, object $record, bool $success, string $logText = ''): bool { - $notifyEmail = trim($profile->notify_email ?? ''); + $notifyEmail = trim($profile->notify_email ?? ''); + $notifyUserGroups = $profile->notify_user_groups ?? ''; - if (empty($notifyEmail)) { + // Resolve user group members to email addresses + $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); + + if (empty($notifyEmail) && empty($groupEmails)) { return false; } @@ -54,9 +58,10 @@ class NotificationSender $siteName = $config->get('sitename', 'Joomla Site'); $siteUrl = Uri::root(); - // Parse recipient list (comma-separated) + // Parse recipient list (comma-separated) + user group emails $recipients = array_map('trim', explode(',', $notifyEmail)); - $recipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)); + $recipients = array_merge($recipients, $groupEmails); + $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); if (empty($recipients)) { return false; @@ -133,4 +138,41 @@ class NotificationSender 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) { + return []; + } + } } diff --git a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php index eb334670..70999576 100644 --- a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php +++ b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php @@ -89,12 +89,15 @@ class RestoreEngine // Step 1: Extract archive to staging $this->log('Extracting archive: ' . basename($archivePath)); - // Detect format: JPA or ZIP + // Detect format: JPA, tar.gz, or ZIP if (JpaUnarchiver::isJpaFile($archivePath)) { $this->log('Detected JPA format (Akeeba Backup archive)'); $jpa = new JpaUnarchiver($archivePath, $this->stagingDir); $count = $jpa->extract(); $this->log('Extracted ' . $count . ' files from JPA'); + } elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) { + $this->log('Detected tar.gz format'); + $this->extractTarGz($archivePath); } else { $this->extractArchive($archivePath, $password); } @@ -200,6 +203,16 @@ class RestoreEngine $zip->close(); } + /** + * Extract a tar.gz archive to the staging directory. + */ + private function extractTarGz(string $archivePath): void + { + $phar = new \PharData($archivePath); + $phar->extractTo($this->stagingDir, null, true); + $this->log('Extracted tar.gz archive'); + } + /** * Recursively delete a directory and all its contents. */ diff --git a/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php b/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php new file mode 100644 index 00000000..fdce0ce9 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/TarGzArchiver.php @@ -0,0 +1,63 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class TarGzArchiver implements ArchiverInterface +{ + private \PharData $tar; + private string $tarPath; + + public function open(string $path): void + { + // PharData creates .tar first, then we compress to .tar.gz + // Strip .gz to get the .tar path for initial creation + $this->tarPath = preg_replace('/\.gz$/', '', $path); + + // Remove existing files to avoid "already exists" errors + if (is_file($this->tarPath)) { + @unlink($this->tarPath); + } + + if (is_file($path)) { + @unlink($path); + } + + $this->tar = new \PharData($this->tarPath); + } + + public function addFromString(string $localName, string $contents): void + { + $this->tar->addFromString($localName, $contents); + } + + public function addFile(string $filePath, string $localName): void + { + $this->tar->addFile($filePath, $localName); + } + + public function close(): void + { + // Compress the .tar to .tar.gz + $this->tar->compress(\Phar::GZ); + + // Remove the uncompressed .tar + if (is_file($this->tarPath)) { + @unlink($this->tarPath); + } + } + + public function getExtension(): string + { + return 'tar.gz'; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/ZipArchiver.php b/src/packages/com_mokobackup/src/Engine/ZipArchiver.php new file mode 100644 index 00000000..e161035d --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/ZipArchiver.php @@ -0,0 +1,47 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class ZipArchiver implements ArchiverInterface +{ + private \ZipArchive $zip; + + public function open(string $path): void + { + $this->zip = new \ZipArchive(); + + if ($this->zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create ZIP archive: ' . $path); + } + } + + public function addFromString(string $localName, string $contents): void + { + $this->zip->addFromString($localName, $contents); + } + + public function addFile(string $filePath, string $localName): void + { + $this->zip->addFile($filePath, $localName); + } + + public function close(): void + { + $this->zip->close(); + } + + public function getExtension(): string + { + return 'zip'; + } +} diff --git a/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php b/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php new file mode 100644 index 00000000..e5630177 --- /dev/null +++ b/src/packages/com_mokobackup/src/Field/DatabaseTablesField.php @@ -0,0 +1,105 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class DatabaseTablesField extends FormField +{ + protected $type = 'DatabaseTables'; + + protected function getInput(): string + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + // Parse current exclusions (newline-separated) + $excluded = []; + + if (!empty($this->value)) { + $excluded = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))); + } + + // Normalize: replace literal #__ with actual prefix for comparison + $excludedNormalized = array_map(function ($t) use ($prefix) { + return str_replace('#__', $prefix, $t); + }, $excluded); + + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + + $html = '
'; + $html .= ''; + $html .= '
' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '
'; + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($tables as $table) { + $isExcluded = \in_array($table, $excludedNormalized, true); + + // Convert to #__ notation for storage + $storeValue = $table; + + if (str_starts_with($table, $prefix)) { + $storeValue = '#__' . substr($table, \strlen($prefix)); + } + + $safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8'); + $safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8'); + $checked = $isExcluded ? ' checked' : ''; + + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '
' . $safeTable . '
'; + + // Script to sync checkboxes to hidden field + $html .= << +SCRIPT; + + return $html; + } +} diff --git a/src/packages/com_mokobackup/src/Field/ExcludeListField.php b/src/packages/com_mokobackup/src/Field/ExcludeListField.php new file mode 100644 index 00000000..483e68cd --- /dev/null +++ b/src/packages/com_mokobackup/src/Field/ExcludeListField.php @@ -0,0 +1,120 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class ExcludeListField extends FormField +{ + protected $type = 'ExcludeList'; + + protected function getInput(): string + { + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + $placeholder = htmlspecialchars((string) ($this->element['hint'] ?? ''), ENT_QUOTES, 'UTF-8'); + + // Parse current values (newline-separated) + $items = []; + + if (!empty($this->value)) { + $items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))))); + } + + $html = '
'; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($items as $item) { + $safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8'); + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
'; + $html .= ''; + $html .= '
'; + + $html .= << +SCRIPT; + + return $html; + } +}