Compare commits
1 Commits
development
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c469f0dae |
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user