1 Commits

Author SHA1 Message Date
Jonathan Miller 3c469f0dae feat: placeholder support, download fix, table exclusion modes, log viewer, detail view
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
- Add PlaceholderResolver for [host], [date], [profile_name], etc. in
  backup_dir and new archive_name_format profile field
- Fix download ERR_INVALID_RESPONSE by flushing output buffers before
  sending file headers
- Table exclusions now have separate Data and Structure checkboxes
  (backward compatible with existing newline format)
- Backup log files written alongside archive for posterity
- Log viewer modal in backup records list and inline in detail view
- Clickable record description links to detail view with checksum,
  file path, DB size, and full log
- Dashboard health check shows actual resolved backup directory path
- Fix update site link to use list view (avoids Joomla core bug)
- Schema migration 01.01.09 for archive_name_format column

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 00:11:46 -05:00
16 changed files with 584 additions and 73 deletions
@@ -70,6 +70,15 @@
default="administrator/components/com_mokobackup/backups" default="administrator/components/com_mokobackup/backups"
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field" addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/> />
<field
name="archive_name_format"
type="text"
label="COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
description="COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
default="[host]_[datetime]_profile[profile_id]"
maxlength="512"
hint="[host]_[datetime]_profile[profile_id]"
/>
<field <field
name="include_mokorestore" name="include_mokorestore"
type="radio" type="radio"
@@ -35,6 +35,11 @@ COM_MOKOBACKUP_DOWNLOAD="Download"
; Backup detail view ; Backup detail view
COM_MOKOBACKUP_BACKUP_DETAIL="Backup Detail" COM_MOKOBACKUP_BACKUP_DETAIL="Backup Detail"
COM_MOKOBACKUP_VIEW_LOG="Backup Log"
COM_MOKOBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
COM_MOKOBACKUP_FIELD_PATH="File Path"
COM_MOKOBACKUP_FIELD_DB_SIZE="DB Size"
COM_MOKOBACKUP_FIELD_REMOTE="Remote Path"
; Profiles view ; Profiles view
COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles" COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles"
@@ -89,7 +94,9 @@ COM_MOKOBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the bac
COM_MOKOBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)" COM_MOKOBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting." COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
COM_MOKOBACKUP_FIELD_BACKUP_DIR="Backup Directory" COM_MOKOBACKUP_FIELD_BACKUP_DIR="Backup Directory"
COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Relative path from Joomla root where backup archives are stored" COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]."
COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script" COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed." COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
@@ -236,7 +243,9 @@ COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
; Exclude fields ; 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_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude."
COM_MOKOBACKUP_FIELD_EXCLUDE_DATA="Data"
COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name" COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
; User group notifications ; User group notifications
@@ -56,7 +56,14 @@ COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists" COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found" COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" 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_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude."
COM_MOKOBACKUP_FIELD_EXCLUDE_DATA="Data"
COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name" COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
COM_MOKOBACKUP_VIEW_LOG="Backup Log"
COM_MOKOBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
COM_MOKOBACKUP_FIELD_PATH="File Path"
COM_MOKOBACKUP_FIELD_DB_SIZE="DB Size"
COM_MOKOBACKUP_FIELD_REMOTE="Remote Path"
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups" 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." COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max', `compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part', `split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
`backup_dir` VARCHAR(512) NOT NULL DEFAULT 'administrator/components/com_mokobackup/backups', `backup_dir` VARCHAR(512) NOT NULL DEFAULT 'administrator/components/com_mokobackup/backups',
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
@@ -0,0 +1,3 @@
-- MokoJoomBackup 01.01.09
-- Add archive_name_format column with placeholder support
ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
@@ -125,6 +125,58 @@ class AjaxController extends BaseController
]); ]);
} }
/**
* Load and return the log file contents for a backup record.
* POST: task=ajax.viewLog&id=123
*/
public function viewLog(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
return;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
return;
}
$db = \Joomla\CMS\Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['absolute_path', 'log']))
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$this->sendJson(['error' => true, 'message' => 'Record not found']);
return;
}
// Try to load log from file alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
$logContent = '';
if (is_file($logPath)) {
$logContent = file_get_contents($logPath);
} elseif (!empty($record->log)) {
// Fall back to database-stored log
$logContent = $record->log;
}
$this->sendJson([
'error' => false,
'log' => $logContent ?: '(no log available)',
'source' => is_file($logPath) ? 'file' : 'database',
]);
}
/** /**
* Send a JSON response and close the application. * Send a JSON response and close the application.
*/ */
@@ -68,17 +68,28 @@ class BackupsController extends AdminController
return; return;
} }
$app = $this->app; // Flush any output buffers to prevent HTML mixing with binary data
$app->clearHeaders(); while (@ob_end_clean()) {
$app->setHeader('Content-Type', 'application/zip'); // clear all buffers
$app->setHeader('Content-Disposition', 'attachment; filename="' . basename($item->archivename) . '"'); }
$app->setHeader('Content-Length', (string) filesize($item->absolute_path));
$app->setHeader('Cache-Control', 'no-cache, must-revalidate'); $filename = basename($item->archivename);
$app->sendHeaders(); $filesize = filesize($item->absolute_path);
// Detect content type from file extension
$contentType = str_ends_with($filename, '.tar.gz')
? 'application/gzip'
: 'application/zip';
header('Content-Type: ' . $contentType);
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . $filesize);
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($item->absolute_path); readfile($item->absolute_path);
$app->close(); $this->app->close();
} }
/** /**
@@ -60,8 +60,11 @@ class BackupEngine
$excludeFiles = $this->parseNewlineList($profile->exclude_files ?? ''); $excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
$excludeTables = $this->parseNewlineList($profile->exclude_tables ?? ''); $excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
// Determine backup directory // Resolve placeholders in directory and filename
$this->backupDir = JPATH_ROOT . '/' . ($profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'); $resolver = new PlaceholderResolver($profile);
$configuredDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups';
$this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir));
if (!is_dir($this->backupDir)) { if (!is_dir($this->backupDir)) {
mkdir($this->backupDir, 0755, true); mkdir($this->backupDir, 0755, true);
@@ -69,12 +72,12 @@ class BackupEngine
// Create backup record // Create backup record
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$tag = date('Ymd_His'); $tag = $resolver->getTag();
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
$archiveFormat = $profile->archive_format ?? 'zip'; $archiveFormat = $profile->archive_format ?? 'zip';
$archiver = $this->createArchiver($archiveFormat); $archiver = $this->createArchiver($archiveFormat);
$archiveExt = $archiver->getExtension(); $archiveExt = $archiver->getExtension();
$archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.' . $archiveExt; $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
if (empty($description)) { if (empty($description)) {
$description = $profile->title . ' — ' . $now; $description = $profile->title . ' — ' . $now;
@@ -233,6 +236,11 @@ class BackupEngine
} }
} }
// Write log file alongside the archive
$logContent = implode("\n", $this->log);
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
@file_put_contents($logPath, $logContent);
// Final record update // Final record update
$update = (object) [ $update = (object) [
'id' => $recordId, 'id' => $recordId,
@@ -246,7 +254,7 @@ class BackupEngine
'remote_filename' => $remoteFilename, 'remote_filename' => $remoteFilename,
'checksum' => $checksum, 'checksum' => $checksum,
'manifest' => !empty($manifest) ? json_encode($manifest) : '', 'manifest' => !empty($manifest) ? json_encode($manifest) : '',
'log' => implode("\n", $this->log), 'log' => $logContent,
]; ];
$db->updateObject('#__mokobackup_records', $update, 'id'); $db->updateObject('#__mokobackup_records', $update, 'id');
@@ -489,6 +497,19 @@ class BackupEngine
} }
} }
/**
* Resolve a backup directory path. Absolute paths are used as-is,
* relative paths are resolved from JPATH_ROOT.
*/
private function resolveBackupDir(string $dir): string
{
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return rtrim($dir, '/\\');
}
return JPATH_ROOT . '/' . $dir;
}
private function log(string $message): void private function log(string $message): void
{ {
$this->log[] = '[' . date('H:i:s') . '] ' . $message; $this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -16,15 +16,33 @@ use Joomla\CMS\Factory;
class DatabaseDumper class DatabaseDumper
{ {
private array $excludeTables; /** @var array Tables to exclude entirely (both structure and data) */
private array $excludeBoth = [];
/** @var array Tables to exclude data only (structure is kept) */
private array $excludeDataOnly = [];
/** @var array Tables to exclude structure only (data is kept — unusual) */
private array $excludeStructureOnly = [];
private int $tablesCount = 0; private int $tablesCount = 0;
/** /**
* @param array $excludeTables Table names to exclude (with #__ prefix) * @param array $excludeTables Table names to exclude (with #__ prefix).
* Supports suffixes: :data-only, :structure-only.
* No suffix = exclude both (backward compatible).
*/ */
public function __construct(array $excludeTables = []) public function __construct(array $excludeTables = [])
{ {
$this->excludeTables = $excludeTables; foreach ($excludeTables as $entry) {
if (str_ends_with($entry, ':data-only')) {
$this->excludeDataOnly[] = substr($entry, 0, -10);
} elseif (str_ends_with($entry, ':structure-only')) {
$this->excludeStructureOnly[] = substr($entry, 0, -15);
} else {
$this->excludeBoth[] = $entry;
}
}
} }
/** /**
@@ -62,13 +80,31 @@ class DatabaseDumper
// Check if excluded // Check if excluded
$abstractName = '#__' . substr($table, strlen($prefix)); $abstractName = '#__' . substr($table, strlen($prefix));
if ($this->isExcluded($abstractName, $table)) { if ($this->isExcludedBoth($abstractName, $table)) {
continue; continue;
} }
$skipData = $this->isExcludedDataOnly($abstractName, $table);
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
$this->tablesCount++; $this->tablesCount++;
// Get CREATE TABLE statement $output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
if ($skipData) {
$output[] = '-- (data excluded)';
}
if ($skipStructure) {
$output[] = '-- (structure excluded)';
}
$output[] = '-- --------------------------------------------------------';
$output[] = '';
// Get CREATE TABLE statement (unless structure is excluded)
if (!$skipStructure) {
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow(); $createRow = $db->loadRow();
@@ -76,15 +112,17 @@ class DatabaseDumper
continue; continue;
} }
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- --------------------------------------------------------';
$output[] = '';
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';'; $output[] = $createRow[1] . ';';
$output[] = ''; $output[] = '';
}
// Dump data (unless data is excluded)
if ($skipData) {
$output[] = '';
continue;
}
// Dump data in chunks
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
$rowCount = (int) $db->loadResult(); $rowCount = (int) $db->loadResult();
@@ -135,11 +173,39 @@ class DatabaseDumper
} }
/** /**
* Check if a table is excluded. * Check if a table is fully excluded (both data and structure).
*/ */
private function isExcluded(string $abstractName, string $realName): bool private function isExcludedBoth(string $abstractName, string $realName): bool
{ {
foreach ($this->excludeTables as $pattern) { foreach ($this->excludeBoth as $pattern) {
if ($pattern === $abstractName || $pattern === $realName) {
return true;
}
}
return false;
}
/**
* Check if a table's data is excluded (structure only).
*/
private function isExcludedDataOnly(string $abstractName, string $realName): bool
{
foreach ($this->excludeDataOnly as $pattern) {
if ($pattern === $abstractName || $pattern === $realName) {
return true;
}
}
return false;
}
/**
* Check if a table's structure is excluded (data only).
*/
private function isExcludedStructureOnly(string $abstractName, string $realName): bool
{
foreach ($this->excludeStructureOnly as $pattern) {
if ($pattern === $abstractName || $pattern === $realName) { if ($pattern === $abstractName || $pattern === $realName) {
return true; return true;
} }
@@ -0,0 +1,122 @@
<?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
*
* Resolves placeholders like [host], [date], [profile_name] in backup
* directory paths and archive filename formats.
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class PlaceholderResolver
{
/**
* Supported placeholders and their descriptions (for documentation).
*/
public const PLACEHOLDERS = [
'[host]' => 'Server hostname',
'[date]' => 'Date as Ymd (e.g. 20260604)',
'[time]' => 'Time as His (e.g. 143025)',
'[datetime]' => 'Date and time as Ymd_His',
'[year]' => 'Four-digit year',
'[month]' => 'Two-digit month',
'[day]' => 'Two-digit day',
'[hour]' => 'Two-digit hour (24h)',
'[minute]' => 'Two-digit minute',
'[second]' => 'Two-digit second',
'[profile_id]' => 'Backup profile ID',
'[profile_name]' => 'Profile title (sanitized)',
'[site_name]' => 'Joomla site name (sanitized)',
'[type]' => 'Backup type (full, database, files, differential)',
'[random]' => 'Random 6-character hex string',
];
private array $replacements;
/**
* @param object $profile The backup profile object
*/
public function __construct(object $profile)
{
$now = new \DateTimeImmutable('now');
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
$siteName = '';
try {
$siteName = Factory::getApplication()->get('sitename', '');
} catch (\Throwable $e) {
// Fallback: not critical
}
$this->replacements = [
'[host]' => $hostname,
'[date]' => $now->format('Ymd'),
'[time]' => $now->format('His'),
'[datetime]' => $now->format('Ymd_His'),
'[year]' => $now->format('Y'),
'[month]' => $now->format('m'),
'[day]' => $now->format('d'),
'[hour]' => $now->format('H'),
'[minute]' => $now->format('i'),
'[second]' => $now->format('s'),
'[profile_id]' => (string) ($profile->id ?? '0'),
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
'[type]' => $profile->backup_type ?? 'full',
'[random]' => bin2hex(random_bytes(3)),
];
}
/**
* Replace all placeholders in a string.
*
* @param string $template String containing [placeholder] tokens
*
* @return string Resolved string
*/
public function resolve(string $template): string
{
return str_replace(
array_keys($this->replacements),
array_values($this->replacements),
$template
);
}
/**
* Get the raw hostname value (for backward compatibility).
*/
public function getHostname(): string
{
return $this->replacements['[host]'];
}
/**
* Get the datetime tag value (for backward compatibility).
*/
public function getTag(): string
{
return $this->replacements['[datetime]'];
}
/**
* Sanitize a string for use in filenames/paths.
* Keeps alphanumerics, dots, hyphens, underscores. Replaces spaces with hyphens.
*/
private function sanitize(string $value): string
{
$value = str_replace(' ', '-', trim($value));
return preg_replace('/[^a-zA-Z0-9._-]/', '', $value);
}
}
@@ -60,17 +60,18 @@ class SteppedBackupEngine
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
// Build archive path // Resolve placeholders in directory and filename
$backupDir = JPATH_ROOT . '/' . $session->backupDir; $resolver = new PlaceholderResolver($profile);
$backupDir = $this->resolveBackupDir($resolver->resolve($session->backupDir));
if (!is_dir($backupDir)) { if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true); mkdir($backupDir, 0755, true);
} }
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$tag = date('Ymd_His'); $tag = $resolver->getTag();
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
$archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; $archiveName = $resolver->resolve($nameFormat) . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName; $session->archivePath = $backupDir . '/' . $archiveName;
$session->archiveName = $archiveName; $session->archiveName = $archiveName;
@@ -409,11 +410,17 @@ class SteppedBackupEngine
private function completeRecord(SteppedSession $session): void private function completeRecord(SteppedSession $session): void
{ {
$db = Factory::getDbo(); $db = Factory::getDbo();
$logContent = implode("\n", $session->log);
// Write log file alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $session->archivePath);
@file_put_contents($logPath, $logContent);
$update = (object) [ $update = (object) [
'id' => $session->recordId, 'id' => $session->recordId,
'status' => 'complete', 'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'), 'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $session->log), 'log' => $logContent,
]; ];
$db->updateObject('#__mokobackup_records', $update, 'id'); $db->updateObject('#__mokobackup_records', $update, 'id');
@@ -536,6 +543,19 @@ class SteppedBackupEngine
return $tables; return $tables;
} }
/**
* Resolve a backup directory path. Absolute paths are used as-is,
* relative paths are resolved from JPATH_ROOT.
*/
private function resolveBackupDir(string $dir): string
{
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return rtrim($dir, '/\\');
}
return JPATH_ROOT . '/' . $dir;
}
private function parseNewlineList(string $text): array private function parseNewlineList(string $text): array
{ {
if (empty($text)) { if (empty($text)) {
@@ -26,17 +26,29 @@ class DatabaseTablesField extends FormField
$tables = $db->getTableList(); $tables = $db->getTableList();
$prefix = $db->getPrefix(); $prefix = $db->getPrefix();
// Parse current exclusions (newline-separated) // Parse current exclusions (newline-separated, with optional :data-only suffix)
$excluded = []; $excludeData = [];
$excludeStructure = [];
if (!empty($this->value)) { if (!empty($this->value)) {
$excluded = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))); $lines = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))));
}
// Normalize: replace literal #__ with actual prefix for comparison foreach ($lines as $line) {
$excludedNormalized = array_map(function ($t) use ($prefix) { // Normalize table name to real prefix for comparison
return str_replace('#__', $prefix, $t); if (str_ends_with($line, ':data-only')) {
}, $excluded); $tableName = str_replace('#__', $prefix, substr($line, 0, -10));
$excludeData[$tableName] = true;
} elseif (str_ends_with($line, ':structure-only')) {
$tableName = str_replace('#__', $prefix, substr($line, 0, -15));
$excludeStructure[$tableName] = true;
} else {
// No suffix = exclude both (backward compatible)
$tableName = str_replace('#__', $prefix, $line);
$excludeData[$tableName] = true;
$excludeStructure[$tableName] = true;
}
}
}
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
@@ -47,12 +59,16 @@ class DatabaseTablesField extends FormField
$html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">'; $html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">';
$html .= '<table class="table table-sm table-hover mb-0">'; $html .= '<table class="table table-sm table-hover mb-0">';
$html .= '<thead class="sticky-top bg-white"><tr>'; $html .= '<thead class="sticky-top bg-white"><tr>';
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleAll" /></th>'; $html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleData" title="Toggle all data" /></th>';
$html .= '<th class="w-1">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_DATA') . '</th>';
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleStructure" title="Toggle all structure" /></th>';
$html .= '<th class="w-1">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE') . '</th>';
$html .= '<th>' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '</th>'; $html .= '<th>' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '</th>';
$html .= '</tr></thead><tbody>'; $html .= '</tr></thead><tbody>';
foreach ($tables as $table) { foreach ($tables as $table) {
$isExcluded = \in_array($table, $excludedNormalized, true); $dataChecked = isset($excludeData[$table]) ? ' checked' : '';
$structureChecked = isset($excludeStructure[$table]) ? ' checked' : '';
// Convert to #__ notation for storage // Convert to #__ notation for storage
$storeValue = $table; $storeValue = $table;
@@ -63,10 +79,12 @@ class DatabaseTablesField extends FormField
$safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8'); $safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8');
$safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8'); $safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8');
$checked = $isExcluded ? ' checked' : '';
$html .= '<tr>'; $html .= '<tr>';
$html .= '<td><input type="checkbox" class="' . $id . '_cb" value="' . $safeValue . '"' . $checked . ' /></td>'; $html .= '<td></td>';
$html .= '<td><input type="checkbox" class="' . $id . '_data" value="' . $safeValue . '"' . $dataChecked . ' /></td>';
$html .= '<td></td>';
$html .= '<td><input type="checkbox" class="' . $id . '_structure" value="' . $safeValue . '"' . $structureChecked . ' /></td>';
$html .= '<td><code>' . $safeTable . '</code></td>'; $html .= '<td><code>' . $safeTable . '</code></td>';
$html .= '</tr>'; $html .= '</tr>';
} }
@@ -78,20 +96,44 @@ class DatabaseTablesField extends FormField
<script> <script>
(function() { (function() {
var hidden = document.getElementById('{$id}'); var hidden = document.getElementById('{$id}');
var cbs = document.querySelectorAll('.{$id}_cb'); var dataCbs = document.querySelectorAll('.{$id}_data');
var toggleAll = document.getElementById('{$id}_toggleAll'); var structCbs = document.querySelectorAll('.{$id}_structure');
var toggleData = document.getElementById('{$id}_toggleData');
var toggleStructure = document.getElementById('{$id}_toggleStructure');
function sync() { function sync() {
var vals = []; var result = {};
cbs.forEach(function(cb) { if (cb.checked) vals.push(cb.value); }); dataCbs.forEach(function(cb) {
hidden.value = vals.join('\\n'); if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 1;
});
structCbs.forEach(function(cb) {
if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 2;
});
var lines = [];
for (var table in result) {
if (result[table] === 3) {
lines.push(table);
} else if (result[table] === 1) {
lines.push(table + ':data-only');
} else if (result[table] === 2) {
lines.push(table + ':structure-only');
}
}
hidden.value = lines.join('\\n');
} }
cbs.forEach(function(cb) { cb.addEventListener('change', sync); }); dataCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
structCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
toggleAll.addEventListener('change', function() { toggleData.addEventListener('change', function() {
var state = this.checked; var state = this.checked;
cbs.forEach(function(cb) { cb.checked = state; }); dataCbs.forEach(function(cb) { cb.checked = state; });
sync();
});
toggleStructure.addEventListener('change', function() {
var state = this.checked;
structCbs.forEach(function(cb) { cb.checked = state; });
sync(); sync();
}); });
@@ -122,13 +122,35 @@ class DashboardModel extends BaseDatabaseModel
'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+', 'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
]; ];
// Backup directory writable // Backup directory writable — check the default path
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups'; $defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
$backupDir = $defaultDir;
// If profiles use a custom directory, check that instead
$db2 = $this->getDatabase();
$qDir = $db2->getQuery(true)
->select($db2->quoteName('backup_dir'))
->from($db2->quoteName('#__mokobackup_profiles'))
->where($db2->quoteName('published') . ' = 1')
->where($db2->quoteName('backup_dir') . ' != ' . $db2->quote(''))
->where($db2->quoteName('backup_dir') . ' IS NOT NULL');
$db2->setQuery($qDir, 0, 1);
$profileDir = $db2->loadResult();
if ($profileDir) {
// Absolute paths used as-is, relative resolved from JPATH_ROOT
if ($profileDir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $profileDir)) {
$backupDir = rtrim($profileDir, '/\\');
} else {
$backupDir = JPATH_ROOT . '/' . $profileDir;
}
}
$writable = is_dir($backupDir) && is_writable($backupDir); $writable = is_dir($backupDir) && is_writable($backupDir);
$checks[] = (object) [ $checks[] = (object) [
'label' => 'Backup Directory', 'label' => 'Backup Directory',
'status' => $writable, 'status' => $writable,
'detail' => $writable ? 'Writable' : 'Not writable or missing', 'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir,
]; ];
// Disk space // Disk space
@@ -12,7 +12,11 @@ defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text; use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false);
?> ?>
<div class="main-card"> <div class="main-card">
<div class="card-body"> <div class="card-body">
@@ -22,7 +26,17 @@ use Joomla\CMS\Language\Text;
<tbody> <tbody>
<tr> <tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_STATUS'); ?></th> <th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_STATUS'); ?></th>
<td><?php echo $this->escape($this->item->status); ?></td> <td>
<?php
$statusClass = match ($this->item->status) {
'complete' => 'badge bg-success',
'running' => 'badge bg-info',
'fail' => 'badge bg-danger',
default => 'badge bg-secondary',
};
?>
<span class="<?php echo $statusClass; ?>"><?php echo $this->escape($this->item->status); ?></span>
</td>
</tr> </tr>
<tr> <tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_BACKUP_TYPE'); ?></th> <th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_BACKUP_TYPE'); ?></th>
@@ -34,7 +48,12 @@ use Joomla\CMS\Language\Text;
</tr> </tr>
<tr> <tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_SIZE'); ?></th> <th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_SIZE'); ?></th>
<td><?php echo HTMLHelper::_('number.bytes', $this->item->total_size); ?></td> <td>
<?php echo HTMLHelper::_('number.bytes', $this->item->total_size); ?>
<?php if ($this->item->db_size > 0) : ?>
<small class="text-muted">(<?php echo Text::_('COM_MOKOBACKUP_FIELD_DB_SIZE'); ?>: <?php echo HTMLHelper::_('number.bytes', $this->item->db_size); ?>)</small>
<?php endif; ?>
</td>
</tr> </tr>
<tr> <tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_START'); ?></th> <th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_START'); ?></th>
@@ -46,7 +65,11 @@ use Joomla\CMS\Language\Text;
</tr> </tr>
<tr> <tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_ARCHIVE'); ?></th> <th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_ARCHIVE'); ?></th>
<td><?php echo $this->escape($this->item->archivename); ?></td> <td><code><?php echo $this->escape($this->item->archivename); ?></code></td>
</tr>
<tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_PATH'); ?></th>
<td><code><?php echo $this->escape($this->item->absolute_path); ?></code></td>
</tr> </tr>
<tr> <tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_FILES_COUNT'); ?></th> <th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_FILES_COUNT'); ?></th>
@@ -56,7 +79,47 @@ use Joomla\CMS\Language\Text;
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_TABLES_COUNT'); ?></th> <th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_TABLES_COUNT'); ?></th>
<td><?php echo (int) $this->item->tables_count; ?></td> <td><?php echo (int) $this->item->tables_count; ?></td>
</tr> </tr>
<?php if (!empty($this->item->checksum)) : ?>
<tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_CHECKSUM'); ?></th>
<td><code class="font-monospace" style="font-size:0.85em;"><?php echo $this->escape($this->item->checksum); ?></code></td>
</tr>
<?php endif; ?>
<?php if (!empty($this->item->remote_filename)) : ?>
<tr>
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_REMOTE'); ?></th>
<td><code><?php echo $this->escape($this->item->remote_filename); ?></code></td>
</tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
<!-- Backup Log -->
<h4 class="mt-4"><?php echo Text::_('COM_MOKOBACKUP_VIEW_LOG'); ?></h4>
<div id="mb-detail-log" class="bg-light p-3 rounded" style="max-height:400px; overflow-y:auto;">
<pre id="mb-detail-log-body" style="white-space:pre-wrap; word-break:break-word; font-size:0.85rem; margin:0;">Loading...</pre>
</div>
</div> </div>
</div> </div>
<script>
(function() {
var form = new URLSearchParams();
form.append('task', 'ajax.viewLog');
form.append('id', <?php echo (int) $this->item->id; ?>);
form.append(<?php echo json_encode($ajaxToken); ?>, '1');
fetch(<?php echo json_encode($ajaxUrl); ?>, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
document.getElementById('mb-detail-log-body').textContent = data.error ? data.message : data.log;
})
.catch(function(err) {
document.getElementById('mb-detail-log-body').textContent = 'Error: ' + err.message;
});
})();
</script>
@@ -99,7 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?> <?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
</td> </td>
<td> <td>
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=backup&id=' . $item->id); ?>">
<?php echo $this->escape($item->description); ?> <?php echo $this->escape($item->description); ?>
</a>
<?php if (!empty($item->checksum)) : ?>
<br><small class="text-muted font-monospace"><?php echo Text::_('COM_MOKOBACKUP_FIELD_CHECKSUM'); ?>: <?php echo substr($item->checksum, 0, 16); ?>...</small>
<?php endif; ?>
</td> </td>
<td> <td>
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?> <?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
@@ -130,13 +135,18 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<td> <td>
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?> <?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
</td> </td>
<td> <td class="d-flex gap-1">
<?php if ($item->status === 'complete' && $item->filesexist) : ?> <?php if ($item->status === 'complete' && $item->filesexist) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokobackup&task=backups.download&id=' . $item->id); ?>" <a href="<?php echo Route::_('index.php?option=com_mokobackup&task=backups.download&id=' . $item->id); ?>"
class="btn btn-sm btn-outline-primary" title="<?php echo Text::_('COM_MOKOBACKUP_DOWNLOAD'); ?>"> class="btn btn-sm btn-outline-primary" title="<?php echo Text::_('COM_MOKOBACKUP_DOWNLOAD'); ?>">
<span class="icon-download"></span> <span class="icon-download"></span>
</a> </a>
<?php endif; ?> <?php endif; ?>
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
data-id="<?php echo (int) $item->id; ?>"
title="<?php echo Text::_('COM_MOKOBACKUP_VIEW_LOG'); ?>">
<span class="icon-file-alt"></span>
</button>
</td> </td>
<td> <td>
<?php echo (int) $item->id; ?> <?php echo (int) $item->id; ?>
@@ -274,5 +284,58 @@ $listDirn = $this->escape($this->state->get('list.direction'));
// Expose for toolbar button // Expose for toolbar button
window.mokobackupStart = startSteppedBackup; window.mokobackupStart = startSteppedBackup;
// View Log modal handler
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mb-view-log');
if (!btn) return;
e.preventDefault();
var recordId = btn.getAttribute('data-id');
var modal = document.getElementById('mb-log-modal');
var body = document.getElementById('mb-log-body');
body.textContent = 'Loading...';
modal.style.display = 'block';
var form = new URLSearchParams();
form.append('task', 'ajax.viewLog');
form.append('id', recordId);
form.append(TOKEN_NAME, '1');
fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
body.textContent = data.message || 'Error loading log';
} else {
body.textContent = data.log;
}
})
.catch(function(err) {
body.textContent = 'Error: ' + err.message;
});
});
document.addEventListener('click', function(e) {
if (e.target.id === 'mb-log-modal' || e.target.classList.contains('mb-log-close')) {
document.getElementById('mb-log-modal').style.display = 'none';
}
});
})(); })();
</script> </script>
<!-- Log Viewer Modal -->
<div id="mb-log-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOBACKUP_VIEW_LOG'); ?></h4>
<button type="button" class="btn-close mb-log-close" aria-label="Close"></button>
</div>
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
<pre id="mb-log-body" style="white-space:pre-wrap; word-break:break-word; font-size:0.85rem; margin:0; background:#f8f9fa; padding:1rem; border-radius:4px;"></pre>
</div>
</div>
</div>
+1 -1
View File
@@ -190,7 +190,7 @@ class Pkg_MokoBackupInstallerScript
if ($updateSiteId > 0) { if ($updateSiteId > 0) {
$editUrl = Route::_( $editUrl = Route::_(
'index.php?option=com_installer&view=updatesites&task=updatesite.edit&id=' . $updateSiteId 'index.php?option=com_installer&view=updatesites&filter[search]=mokobackup'
); );
Factory::getApplication()->enqueueMessage( Factory::getApplication()->enqueueMessage(