feat: tar.gz archives, table checkbox excludes, user group notifications (#32, #33, #34)
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Joomla: Extension CI / Release Readiness Check (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Joomla: Extension CI / Lint & Validate (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (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
Generic: Repo Health / Release configuration (push) 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
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Joomla: Extension CI / Release Readiness Check (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Joomla: Extension CI / Lint & Validate (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (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
Generic: Repo Health / Release configuration (push) 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
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) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
default="zip"
|
||||
>
|
||||
<option value="zip">ZIP</option>
|
||||
<option value="tar.gz">tar.gz</option>
|
||||
</field>
|
||||
<field
|
||||
name="compression_level"
|
||||
@@ -114,30 +115,29 @@
|
||||
<fieldset name="filters" label="COM_MOKOBACKUP_FIELDSET_FILTERS">
|
||||
<field
|
||||
name="exclude_dirs"
|
||||
type="textarea"
|
||||
type="ExcludeList"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC"
|
||||
rows="6"
|
||||
filter="raw"
|
||||
hint="tmp cache logs administrator/logs"
|
||||
hint="tmp"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="exclude_files"
|
||||
type="textarea"
|
||||
type="ExcludeList"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC"
|
||||
rows="4"
|
||||
filter="raw"
|
||||
hint=".gitignore *.bak *.tmp"
|
||||
hint="*.bak"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="exclude_tables"
|
||||
type="textarea"
|
||||
type="DatabaseTables"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_DESC"
|
||||
rows="4"
|
||||
filter="raw"
|
||||
hint="#__session #__mail_queue"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@@ -176,6 +176,14 @@
|
||||
maxlength="512"
|
||||
hint="admin@example.com, backup@example.com"
|
||||
/>
|
||||
<field
|
||||
name="notify_user_groups"
|
||||
type="usergrouplist"
|
||||
label="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS"
|
||||
description="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.list-fancy-select"
|
||||
/>
|
||||
<field
|
||||
name="notify_on_success"
|
||||
type="radio"
|
||||
|
||||
@@ -235,6 +235,14 @@ COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
|
||||
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
|
||||
; Exclude fields
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Checked tables will be skipped during dump."
|
||||
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
|
||||
|
||||
; User group notifications
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
|
||||
|
||||
; Dashboard warnings
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
|
||||
|
||||
@@ -53,3 +53,10 @@ COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
|
||||
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
|
||||
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Checked tables will be skipped during dump."
|
||||
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
|
||||
|
||||
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
|
||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||
`include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
|
||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
-- Fix: allow NULL defaults for manifest and log columns
|
||||
ALTER TABLE `#__mokobackup_records` MODIFY `manifest` LONGTEXT DEFAULT NULL;
|
||||
ALTER TABLE `#__mokobackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
|
||||
|
||||
-- Add user group notifications column to profiles
|
||||
ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @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
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @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
|
||||
*/
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @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
|
||||
*/
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @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
|
||||
*/
|
||||
|
||||
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 = '<div class="mb-2">';
|
||||
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
|
||||
$html .= '<div class="form-text mb-2">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '</div>';
|
||||
$html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">';
|
||||
$html .= '<table class="table table-sm table-hover mb-0">';
|
||||
$html .= '<thead class="sticky-top bg-white"><tr>';
|
||||
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleAll" /></th>';
|
||||
$html .= '<th>' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '</th>';
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
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 .= '<tr>';
|
||||
$html .= '<td><input type="checkbox" class="' . $id . '_cb" value="' . $safeValue . '"' . $checked . ' /></td>';
|
||||
$html .= '<td><code>' . $safeTable . '</code></td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table></div></div>';
|
||||
|
||||
// Script to sync checkboxes to hidden field
|
||||
$html .= <<<SCRIPT
|
||||
<script>
|
||||
(function() {
|
||||
var hidden = document.getElementById('{$id}');
|
||||
var cbs = document.querySelectorAll('.{$id}_cb');
|
||||
var toggleAll = document.getElementById('{$id}_toggleAll');
|
||||
|
||||
function sync() {
|
||||
var vals = [];
|
||||
cbs.forEach(function(cb) { if (cb.checked) vals.push(cb.value); });
|
||||
hidden.value = vals.join('\\n');
|
||||
}
|
||||
|
||||
cbs.forEach(function(cb) { cb.addEventListener('change', sync); });
|
||||
|
||||
toggleAll.addEventListener('change', function() {
|
||||
var state = this.checked;
|
||||
cbs.forEach(function(cb) { cb.checked = state; });
|
||||
sync();
|
||||
});
|
||||
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
SCRIPT;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokobackup
|
||||
* @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
|
||||
*/
|
||||
|
||||
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 = '<div id="' . $id . '_wrapper">';
|
||||
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
|
||||
$html .= '<table class="table table-sm mb-1" id="' . $id . '_table">';
|
||||
$html .= '<tbody>';
|
||||
|
||||
foreach ($items as $item) {
|
||||
$safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
|
||||
$html .= '<tr>';
|
||||
$html .= '<td><input type="text" class="form-control form-control-sm ' . $id . '_input" value="' . $safeItem . '" placeholder="' . $placeholder . '" /></td>';
|
||||
$html .= '<td class="w-1"><button type="button" class="btn btn-sm btn-outline-danger ' . $id . '_remove"><span class="icon-delete" aria-hidden="true"></span></button></td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
$html .= '<button type="button" class="btn btn-sm btn-outline-success" id="' . $id . '_add">';
|
||||
$html .= '<span class="icon-plus" aria-hidden="true"></span> ' . Text::_('JGLOBAL_FIELD_ADD') . '</button>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= <<<SCRIPT
|
||||
<script>
|
||||
(function() {
|
||||
var wrapper = document.getElementById('{$id}_wrapper');
|
||||
var hidden = document.getElementById('{$id}');
|
||||
var tbody = document.querySelector('#{$id}_table tbody');
|
||||
var addBtn = document.getElementById('{$id}_add');
|
||||
var placeholder = '{$placeholder}';
|
||||
|
||||
function sync() {
|
||||
var vals = [];
|
||||
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
|
||||
var v = inp.value.trim();
|
||||
if (v) vals.push(v);
|
||||
});
|
||||
hidden.value = vals.join('\\n');
|
||||
}
|
||||
|
||||
function addRow(value) {
|
||||
var tr = document.createElement('tr');
|
||||
var td1 = document.createElement('td');
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.className = 'form-control form-control-sm {$id}_input';
|
||||
inp.value = value || '';
|
||||
inp.placeholder = placeholder;
|
||||
inp.addEventListener('input', sync);
|
||||
td1.appendChild(inp);
|
||||
|
||||
var td2 = document.createElement('td');
|
||||
td2.className = 'w-1';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-danger {$id}_remove';
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon-delete';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.appendChild(icon);
|
||||
btn.addEventListener('click', function() { tr.remove(); sync(); });
|
||||
td2.appendChild(btn);
|
||||
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
addBtn.addEventListener('click', function() { addRow(''); });
|
||||
|
||||
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
|
||||
inp.addEventListener('input', sync);
|
||||
});
|
||||
|
||||
wrapper.querySelectorAll('.{$id}_remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
btn.closest('tr').remove();
|
||||
sync();
|
||||
});
|
||||
});
|
||||
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
SCRIPT;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user