Compare commits
1 Commits
development
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c469f0dae |
@@ -70,6 +70,15 @@
|
||||
default="administrator/components/com_mokobackup/backups"
|
||||
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
|
||||
name="include_mokorestore"
|
||||
type="radio"
|
||||
|
||||
@@ -35,6 +35,11 @@ COM_MOKOBACKUP_DOWNLOAD="Download"
|
||||
|
||||
; Backup detail view
|
||||
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
|
||||
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_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_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_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)"
|
||||
|
||||
; 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"
|
||||
|
||||
; 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_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_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_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_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',
|
||||
`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',
|
||||
`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_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns 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.
|
||||
*/
|
||||
|
||||
@@ -68,17 +68,28 @@ class BackupsController extends AdminController
|
||||
return;
|
||||
}
|
||||
|
||||
$app = $this->app;
|
||||
$app->clearHeaders();
|
||||
$app->setHeader('Content-Type', 'application/zip');
|
||||
$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');
|
||||
$app->sendHeaders();
|
||||
// Flush any output buffers to prevent HTML mixing with binary data
|
||||
while (@ob_end_clean()) {
|
||||
// clear all buffers
|
||||
}
|
||||
|
||||
$filename = basename($item->archivename);
|
||||
$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);
|
||||
|
||||
$app->close();
|
||||
$this->app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,21 +60,24 @@ class BackupEngine
|
||||
$excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
|
||||
$excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
|
||||
|
||||
// Determine backup directory
|
||||
$this->backupDir = JPATH_ROOT . '/' . ($profile->backup_dir ?: 'administrator/components/com_mokobackup/backups');
|
||||
// Resolve placeholders in directory and filename
|
||||
$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)) {
|
||||
mkdir($this->backupDir, 0755, true);
|
||||
}
|
||||
|
||||
// Create backup record
|
||||
$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'));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||
$archiver = $this->createArchiver($archiveFormat);
|
||||
$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)) {
|
||||
$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
|
||||
$update = (object) [
|
||||
'id' => $recordId,
|
||||
@@ -246,7 +254,7 @@ class BackupEngine
|
||||
'remote_filename' => $remoteFilename,
|
||||
'checksum' => $checksum,
|
||||
'manifest' => !empty($manifest) ? json_encode($manifest) : '',
|
||||
'log' => implode("\n", $this->log),
|
||||
'log' => $logContent,
|
||||
];
|
||||
|
||||
$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
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -16,15 +16,33 @@ use Joomla\CMS\Factory;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @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 = [])
|
||||
{
|
||||
$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,29 +80,49 @@ class DatabaseDumper
|
||||
// Check if excluded
|
||||
$abstractName = '#__' . substr($table, strlen($prefix));
|
||||
|
||||
if ($this->isExcluded($abstractName, $table)) {
|
||||
if ($this->isExcludedBoth($abstractName, $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipData = $this->isExcludedDataOnly($abstractName, $table);
|
||||
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
|
||||
|
||||
$this->tablesCount++;
|
||||
|
||||
// Get CREATE TABLE statement
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '-- Table: ' . $table;
|
||||
|
||||
if (!$createRow || empty($createRow[1])) {
|
||||
continue;
|
||||
if ($skipData) {
|
||||
$output[] = '-- (data excluded)';
|
||||
}
|
||||
|
||||
if ($skipStructure) {
|
||||
$output[] = '-- (structure excluded)';
|
||||
}
|
||||
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '-- Table: ' . $table;
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '';
|
||||
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
|
||||
$output[] = $createRow[1] . ';';
|
||||
$output[] = '';
|
||||
|
||||
// Dump data in chunks
|
||||
// Get CREATE TABLE statement (unless structure is excluded)
|
||||
if (!$skipStructure) {
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
|
||||
if (!$createRow || empty($createRow[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
|
||||
$output[] = $createRow[1] . ';';
|
||||
$output[] = '';
|
||||
}
|
||||
|
||||
// Dump data (unless data is excluded)
|
||||
if ($skipData) {
|
||||
$output[] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
|
||||
$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) {
|
||||
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->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
||||
|
||||
// Build archive path
|
||||
$backupDir = JPATH_ROOT . '/' . $session->backupDir;
|
||||
// Resolve placeholders in directory and filename
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$backupDir = $this->resolveBackupDir($resolver->resolve($session->backupDir));
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
mkdir($backupDir, 0755, true);
|
||||
}
|
||||
|
||||
$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';
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
||||
|
||||
$session->archivePath = $backupDir . '/' . $archiveName;
|
||||
$session->archiveName = $archiveName;
|
||||
@@ -408,12 +409,18 @@ class SteppedBackupEngine
|
||||
*/
|
||||
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) [
|
||||
'id' => $session->recordId,
|
||||
'status' => 'complete',
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'log' => implode("\n", $session->log),
|
||||
'log' => $logContent,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokobackup_records', $update, 'id');
|
||||
@@ -536,6 +543,19 @@ class SteppedBackupEngine
|
||||
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
|
||||
{
|
||||
if (empty($text)) {
|
||||
|
||||
@@ -26,17 +26,29 @@ class DatabaseTablesField extends FormField
|
||||
$tables = $db->getTableList();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
// Parse current exclusions (newline-separated)
|
||||
$excluded = [];
|
||||
// Parse current exclusions (newline-separated, with optional :data-only suffix)
|
||||
$excludeData = [];
|
||||
$excludeStructure = [];
|
||||
|
||||
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
|
||||
$excludedNormalized = array_map(function ($t) use ($prefix) {
|
||||
return str_replace('#__', $prefix, $t);
|
||||
}, $excluded);
|
||||
foreach ($lines as $line) {
|
||||
// Normalize table name to real prefix for comparison
|
||||
if (str_ends_with($line, ':data-only')) {
|
||||
$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');
|
||||
$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 .= '<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 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 .= '</tr></thead><tbody>';
|
||||
|
||||
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
|
||||
$storeValue = $table;
|
||||
@@ -63,10 +79,12 @@ class DatabaseTablesField extends FormField
|
||||
|
||||
$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></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 .= '</tr>';
|
||||
}
|
||||
@@ -78,20 +96,44 @@ class DatabaseTablesField extends FormField
|
||||
<script>
|
||||
(function() {
|
||||
var hidden = document.getElementById('{$id}');
|
||||
var cbs = document.querySelectorAll('.{$id}_cb');
|
||||
var toggleAll = document.getElementById('{$id}_toggleAll');
|
||||
var dataCbs = document.querySelectorAll('.{$id}_data');
|
||||
var structCbs = document.querySelectorAll('.{$id}_structure');
|
||||
var toggleData = document.getElementById('{$id}_toggleData');
|
||||
var toggleStructure = document.getElementById('{$id}_toggleStructure');
|
||||
|
||||
function sync() {
|
||||
var vals = [];
|
||||
cbs.forEach(function(cb) { if (cb.checked) vals.push(cb.value); });
|
||||
hidden.value = vals.join('\\n');
|
||||
var result = {};
|
||||
dataCbs.forEach(function(cb) {
|
||||
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;
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -122,13 +122,35 @@ class DashboardModel extends BaseDatabaseModel
|
||||
'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
|
||||
];
|
||||
|
||||
// Backup directory writable
|
||||
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
|
||||
// Backup directory writable — check the default path
|
||||
$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);
|
||||
$checks[] = (object) [
|
||||
'label' => 'Backup Directory',
|
||||
'status' => $writable,
|
||||
'detail' => $writable ? 'Writable' : 'Not writable or missing',
|
||||
'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir,
|
||||
];
|
||||
|
||||
// Disk space
|
||||
|
||||
@@ -12,7 +12,11 @@ defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
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="card-body">
|
||||
@@ -22,7 +26,17 @@ use Joomla\CMS\Language\Text;
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_BACKUP_TYPE'); ?></th>
|
||||
@@ -34,7 +48,12 @@ use Joomla\CMS\Language\Text;
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_START'); ?></th>
|
||||
@@ -46,7 +65,11 @@ use Joomla\CMS\Language\Text;
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<td><?php echo (int) $this->item->tables_count; ?></td>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->description); ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=backup&id=' . $item->id); ?>">
|
||||
<?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>
|
||||
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
|
||||
@@ -130,13 +135,18 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
</td>
|
||||
<td>
|
||||
<td class="d-flex gap-1">
|
||||
<?php if ($item->status === 'complete' && $item->filesexist) : ?>
|
||||
<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'); ?>">
|
||||
<span class="icon-download"></span>
|
||||
</a>
|
||||
<?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>
|
||||
<?php echo (int) $item->id; ?>
|
||||
@@ -274,5 +284,58 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
|
||||
// Expose for toolbar button
|
||||
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>
|
||||
|
||||
<!-- 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
@@ -190,7 +190,7 @@ class Pkg_MokoBackupInstallerScript
|
||||
|
||||
if ($updateSiteId > 0) {
|
||||
$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(
|
||||
|
||||
Reference in New Issue
Block a user