Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29da9776cd | |||
| 09bac755a9 | |||
| f830dc2ddf | |||
| 5698c074da | |||
| aaf189b87a | |||
| 61023821e6 | |||
| 02a6e30db1 | |||
| 5a0cd51df6 | |||
| 12c832d7fe | |||
| 65c8820db4 | |||
| 0f914c3061 | |||
| 4191f44c1b | |||
| fb99afbeba | |||
| de632e9c5c | |||
| 53ff99148c | |||
| c2ff3b272a | |||
| 747b68c179 | |||
| cbff40d04c | |||
| e415e701cd | |||
| d184ed9de0 | |||
| 297f27c807 | |||
| 30e8d7baa9 | |||
| efc5754bef | |||
| e3e422d29e | |||
| 9f5c8c0b5e | |||
| 044e57adf3 | |||
| e7f165ac96 | |||
| fc41e1801a | |||
| 1aa35dd041 | |||
| 6a1f4a8797 | |||
| 6f6a6c705b | |||
| e8d7d1d421 | |||
| cd31617e21 | |||
| 6d9d96d7cd | |||
| df7c07bec4 | |||
| 5b4717bf6f | |||
| 65d30613b2 | |||
| d5bbab7e72 | |||
| 18b65d30ac | |||
| f55b032cc9 | |||
| e62dba8f40 | |||
| 0619825f38 | |||
| 70d7da34b3 | |||
| 13c251196b | |||
| 4841f24eab | |||
| 64ffbb9d61 | |||
| 83e91c6fa6 | |||
| b1833825e7 | |||
| bde20e82ad | |||
| 8348d23fe4 | |||
| d9557489d5 | |||
| 089ec69595 | |||
| 7427cbb043 | |||
| 456e744d81 |
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.35.04
|
||||
# VERSION: 01.39.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.35.04] --- 2026-06-23
|
||||
## [01.39.00] --- 2026-06-23
|
||||
|
||||
## [01.35.04] --- 2026-06-23
|
||||
## [01.39.00] --- 2026-06-23
|
||||
|
||||
## [01.35.03] --- 2026-06-23
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.35.03] --- 2026-06-23
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.35.01] --- 2026-06-23
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
## [01.35.01] --- 2026-06-23
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.35.04 -->
|
||||
<!-- VERSION: 01.39.00 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
@@ -72,23 +72,25 @@
|
||||
/>
|
||||
<field
|
||||
name="archive_name_format"
|
||||
type="text"
|
||||
type="PlaceholderText"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||
default="[host]_[datetime]_profile[profile_id]"
|
||||
default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||
maxlength="512"
|
||||
hint="[host]_[datetime]_profile[profile_id]"
|
||||
hint="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||
placeholders="[HOST],[DATETIME],[DATE],[TIME],[YEAR],[MONTH],[DAY],[HOUR],[MINUTE],[SECOND],[PROFILE_ID],[PROFILE_NAME],[SITE_NAME],[TYPE],[RANDOM]"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="include_mokorestore"
|
||||
type="radio"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||
</field>
|
||||
<field
|
||||
name="encryption_password"
|
||||
@@ -99,6 +101,54 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
|
||||
<field
|
||||
name="sanitize_passwords"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="preserve_super_admin"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="sanitize_passwords:1"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_emails"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_sessions"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
||||
<field
|
||||
name="id"
|
||||
|
||||
@@ -78,6 +78,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
||||
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
|
||||
|
||||
; Profile actions
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||
|
||||
; Table headings
|
||||
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
|
||||
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
|
||||
@@ -124,11 +130,25 @@ COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the
|
||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [HOST], [DATE], [YEAR], [MONTH], [DAY], [PROFILE_NAME], [SITE_NAME], [TYPE]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
||||
COM_MOKOJOOMBACKUP_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_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
|
||||
COM_MOKOJOOMBACKUP_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_MOKOJOOMBACKUP_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_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
||||
|
||||
; Data Sanitization
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
@@ -415,6 +435,20 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
||||
|
||||
; Snapshot browse / detail view
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules"
|
||||
COM_MOKOJOOMBACKUP_HEADING_STATE="State"
|
||||
COM_MOKOJOOMBACKUP_HEADING_POSITION="Position"
|
||||
COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type"
|
||||
COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level"
|
||||
COM_MOKOJOOMBACKUP_LOADING="Loading..."
|
||||
COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
|
||||
|
||||
; Errors
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
|
||||
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_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 '[DEFAULT_DIR]',
|
||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
|
||||
`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',
|
||||
@@ -39,7 +39,11 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||
`include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
|
||||
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
|
||||
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
|
||||
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
|
||||
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
|
||||
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
|
||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
|
||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
||||
|
||||
-- Add archive_name_format column with placeholder support
|
||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- MokoSuiteBackup 01.39.00 — Change include_mokorestore from TINYINT to VARCHAR
|
||||
-- Needed to support 'standalone' value alongside 0/1
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
MODIFY COLUMN `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
|
||||
@@ -0,0 +1,34 @@
|
||||
-- MokoSuiteBackup 01.39.01 — Uppercase all placeholders in profile data
|
||||
|
||||
UPDATE `#__mokosuitebackup_profiles` SET
|
||||
`archive_name_format` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||
`archive_name_format`,
|
||||
'[host]', '[HOST]'),
|
||||
'[site_name]', '[SITE_NAME]'),
|
||||
'[datetime]', '[DATETIME]'),
|
||||
'[date]', '[DATE]'),
|
||||
'[time]', '[TIME]'),
|
||||
'[year]', '[YEAR]'),
|
||||
'[month]', '[MONTH]'),
|
||||
'[day]', '[DAY]'),
|
||||
'[hour]', '[HOUR]'),
|
||||
'[minute]', '[MINUTE]'),
|
||||
'[second]', '[SECOND]'),
|
||||
'[profile_id]', '[PROFILE_ID]'),
|
||||
'[profile_name]', '[PROFILE_NAME]'),
|
||||
'[type]', '[TYPE]'),
|
||||
'[random]', '[RANDOM]')
|
||||
WHERE `archive_name_format` REGEXP '\\[[a-z]';
|
||||
|
||||
UPDATE `#__mokosuitebackup_profiles` SET
|
||||
`backup_dir` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||
`backup_dir`,
|
||||
'[host]', '[HOST]'),
|
||||
'[site_name]', '[SITE_NAME]'),
|
||||
'[date]', '[DATE]'),
|
||||
'[year]', '[YEAR]'),
|
||||
'[month]', '[MONTH]'),
|
||||
'[day]', '[DAY]'),
|
||||
'[profile_id]', '[PROFILE_ID]'),
|
||||
'[profile_name]', '[PROFILE_NAME]')
|
||||
WHERE `backup_dir` REGEXP '\\[[a-z]';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
|
||||
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
|
||||
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
|
||||
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
|
||||
@@ -15,8 +15,10 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
@@ -283,7 +285,32 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = BackupDirectory::resolve($rawPath);
|
||||
/* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
|
||||
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if ($profileId > 0) {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
}
|
||||
|
||||
if (empty($profile)) {
|
||||
/* No profile context — create a minimal dummy for PlaceholderResolver */
|
||||
$profile = (object) [
|
||||
'id' => 1,
|
||||
'title' => 'default',
|
||||
'backup_type' => 'full',
|
||||
];
|
||||
}
|
||||
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$withNamePlaceholders = $resolver->resolve($rawPath);
|
||||
$resolved = BackupDirectory::resolve($withNamePlaceholders);
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolved)) {
|
||||
$this->sendJson([
|
||||
@@ -622,28 +649,67 @@ class AjaxController extends BaseController
|
||||
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tables = $data['tables'] ?? [];
|
||||
|
||||
// Articles
|
||||
$articles = [];
|
||||
|
||||
foreach ($data['tables']['#__content'] as $row) {
|
||||
$articles[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'catid' => (int) ($row['catid'] ?? 0),
|
||||
'state' => (int) ($row['state'] ?? 0),
|
||||
'created' => $row['created'] ?? '',
|
||||
];
|
||||
if (!empty($tables['#__content'])) {
|
||||
foreach ($tables['#__content'] as $row) {
|
||||
$articles[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'catid' => (int) ($row['catid'] ?? 0),
|
||||
'state' => (int) ($row['state'] ?? 0),
|
||||
'created' => $row['created'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Categories
|
||||
$categories = [];
|
||||
|
||||
if (!empty($tables['#__categories'])) {
|
||||
foreach ($tables['#__categories'] as $row) {
|
||||
$categories[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'extension' => $row['extension'] ?? '',
|
||||
'published' => (int) ($row['published'] ?? 0),
|
||||
'level' => (int) ($row['level'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Modules
|
||||
$modules = [];
|
||||
|
||||
if (!empty($tables['#__modules'])) {
|
||||
foreach ($tables['#__modules'] as $row) {
|
||||
$modules[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'module' => $row['module'] ?? '',
|
||||
'position' => $row['position'] ?? '',
|
||||
'published' => (int) ($row['published'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'total' => count($articles),
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'categories' => $categories,
|
||||
'modules' => $modules,
|
||||
'total_articles' => \count($articles),
|
||||
'total_categories' => \count($categories),
|
||||
'total_modules' => \count($modules),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
||||
|
||||
@@ -34,7 +35,14 @@ class BackupsController extends AdminController
|
||||
*/
|
||||
public function start(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
/* Accept token from both GET (profile Run button) and POST (backup form).
|
||||
Joomla's checkToken() throws on failure, so try GET first. */
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->setMessage(Text::_('JINVALID_TOKEN_NOTICE'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
|
||||
@@ -88,7 +88,7 @@ class BackupEngine
|
||||
$archiveName = '';
|
||||
$archiver = $this->createArchiver($archiveFormat);
|
||||
$archiveExt = $archiver->getExtension();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
||||
|
||||
if (empty($description)) {
|
||||
@@ -137,7 +137,19 @@ class BackupEngine
|
||||
if ($profile->backup_type !== 'files') {
|
||||
$this->log('Starting database dump...');
|
||||
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
|
||||
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
|
||||
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
|
||||
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
|
||||
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
|
||||
|
||||
if ($sanitizePasswords) {
|
||||
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
|
||||
}
|
||||
|
||||
if ($sanitizeEmails) {
|
||||
$this->log('User emails will be sanitized');
|
||||
}
|
||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||
$tablesCount = $dumper->getTablesCount();
|
||||
@@ -237,26 +249,32 @@ class BackupEngine
|
||||
$this->verifyArchive($archivePath, $profile->backup_type);
|
||||
$this->log('Archive integrity verified');
|
||||
|
||||
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
||||
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||
// Step 2.5: MokoRestore script (if enabled)
|
||||
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
||||
$restoreScriptPath = '';
|
||||
|
||||
if ($includeMokoRestore) {
|
||||
if ($mokoRestoreMode === '1') {
|
||||
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
|
||||
$this->log('Wrapping with MokoRestore script...');
|
||||
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
||||
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
||||
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
||||
|
||||
// Replace the original archive with the wrapped one
|
||||
if (is_file($archivePath) && !unlink($archivePath)) {
|
||||
$this->log('WARNING: Could not remove pre-wrap archive');
|
||||
}
|
||||
rename($mokoRestorePath, $archivePath);
|
||||
$totalSize = filesize($archivePath);
|
||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||
// Recompute checksum for the final wrapped archive
|
||||
$checksum = hash_file('sha256', $archivePath);
|
||||
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
||||
$this->log('SHA-256 (wrapped): ' . $checksum);
|
||||
} elseif ($mokoRestoreMode === 'standalone') {
|
||||
// Standalone mode: restore.php as a separate file next to the backup ZIP
|
||||
$this->log('Generating standalone restore.php...');
|
||||
$restoreScriptPath = $this->backupDir . '/restore.php';
|
||||
MokoRestore::generateStandalone($restoreScriptPath);
|
||||
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
||||
}
|
||||
|
||||
$remoteFilename = '';
|
||||
@@ -277,6 +295,18 @@ class BackupEngine
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
|
||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$this->log('Uploading standalone restore.php...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone restore.php uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
|
||||
@@ -27,12 +27,35 @@ class DatabaseDumper
|
||||
|
||||
private int $tablesCount = 0;
|
||||
|
||||
/** @var bool Whether to sanitize user passwords */
|
||||
private bool $sanitizePasswords = false;
|
||||
|
||||
/** @var bool Whether to preserve super admin password when sanitizing */
|
||||
private bool $preserveSuperAdmin = false;
|
||||
|
||||
/** @var bool Whether to sanitize user emails */
|
||||
private bool $sanitizeEmails = false;
|
||||
|
||||
/** @var bool Whether to clear session data */
|
||||
private bool $sanitizeSessions = false;
|
||||
|
||||
/** Known invalid bcrypt hash used for sanitized passwords */
|
||||
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
|
||||
|
||||
/**
|
||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||
* Supports suffixes: :data-only, :structure-only.
|
||||
* No suffix = exclude both (backward compatible).
|
||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||
* @param bool $sanitizePasswords Replace user password hashes with invalid value
|
||||
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
|
||||
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
|
||||
* @param bool $sanitizeSessions Skip session table data entirely
|
||||
*/
|
||||
public function __construct(array $excludeTables = [])
|
||||
public function __construct(
|
||||
array $excludeTables = [],
|
||||
bool $sanitizePasswords = false,
|
||||
bool $preserveSuperAdmin = false,
|
||||
bool $sanitizeEmails = false,
|
||||
bool $sanitizeSessions = false
|
||||
)
|
||||
{
|
||||
foreach ($excludeTables as $entry) {
|
||||
if (str_ends_with($entry, ':data-only')) {
|
||||
@@ -43,6 +66,16 @@ class DatabaseDumper
|
||||
$this->excludeBoth[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sanitizePasswords = $sanitizePasswords;
|
||||
$this->preserveSuperAdmin = $preserveSuperAdmin;
|
||||
$this->sanitizeEmails = $sanitizeEmails;
|
||||
$this->sanitizeSessions = $sanitizeSessions;
|
||||
|
||||
/* If session sanitization is on, auto-exclude session table data */
|
||||
if ($sanitizeSessions) {
|
||||
$this->excludeDataOnly[] = '#__session';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +187,7 @@ class DatabaseDumper
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->sanitizeRow($row, $abstractName, $db);
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
@@ -326,6 +360,7 @@ class DatabaseDumper
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->sanitizeRow($row, $abstractName, $db);
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
@@ -351,6 +386,86 @@ class DatabaseDumper
|
||||
return filesize($filePath) ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a row if it belongs to the users table and sanitization is enabled.
|
||||
*
|
||||
* Replaces the password column with an invalid hash so the backup
|
||||
* cannot be used to extract user credentials.
|
||||
*/
|
||||
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
|
||||
{
|
||||
if ($abstractTable !== '#__users') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
|
||||
$userId = (int) $row['id'];
|
||||
|
||||
/* Preserve super admin emails if preserving super admin */
|
||||
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
|
||||
$row['email'] = 'user' . $userId . '@sanitized.example.com';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->sanitizePasswords || !isset($row['password'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->preserveSuperAdmin && isset($row['id'])) {
|
||||
if ($this->isSuperAdmin((int) $row['id'], $db)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$row['password'] = self::SANITIZED_HASH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user ID belongs to the Super Users group (group_id = 8).
|
||||
*/
|
||||
private function isSuperAdmin(int $userId, object $db): bool
|
||||
{
|
||||
static $superAdminIds = null;
|
||||
|
||||
if ($superAdminIds === null) {
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
try {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('user_id'))
|
||||
->from($db->quoteName($prefix . 'user_usergroup_map'))
|
||||
->where($db->quoteName('group_id') . ' = 8')
|
||||
);
|
||||
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
|
||||
} catch (\Throwable $e) {
|
||||
$superAdminIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
return in_array($userId, $superAdminIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if passwords were sanitized (for use by callers to log the action).
|
||||
*/
|
||||
public function isPasswordSanitizationEnabled(): bool
|
||||
{
|
||||
return $this->sanitizePasswords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sentinel hash used for sanitized passwords.
|
||||
*/
|
||||
public static function getSanitizedHash(): string
|
||||
{
|
||||
return self::SANITIZED_HASH;
|
||||
}
|
||||
|
||||
public function getTablesCount(): int
|
||||
{
|
||||
return $this->tablesCount;
|
||||
|
||||
@@ -54,6 +54,191 @@ class MokoRestore
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script as a separate file.
|
||||
*
|
||||
* Unlike the wrapped version, this script scans its own directory
|
||||
* for ZIP files and lets the user choose which one to restore from.
|
||||
*
|
||||
* @param string $outputPath Where to write restore.php
|
||||
*
|
||||
* @return string Path to the generated script
|
||||
*/
|
||||
public static function generateStandalone(string $outputPath): string
|
||||
{
|
||||
$script = self::generateStandaloneScript();
|
||||
|
||||
if (file_put_contents($outputPath, $script) === false) {
|
||||
throw new \RuntimeException('Cannot write standalone restore script: ' . $outputPath);
|
||||
}
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone script content that scans for ZIPs.
|
||||
*/
|
||||
private static function generateStandaloneScript(): string
|
||||
{
|
||||
/* Take the normal backend but replace the hardcoded BACKUP_FILE
|
||||
with a directory scanner that finds ZIP files */
|
||||
$php = self::generateBackend();
|
||||
|
||||
/* Replace the fixed BACKUP_FILE constant with dynamic scanner */
|
||||
$php = str_replace(
|
||||
"define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');",
|
||||
"/* BACKUP_FILE is set dynamically — see actionSelectBackup() below */\n" .
|
||||
"define('BACKUP_FILE', ''); /* placeholder — overridden per request */",
|
||||
$php
|
||||
);
|
||||
|
||||
/* Inject the backup scanner function after the constants */
|
||||
$scannerCode = <<<'SCANNER'
|
||||
|
||||
/**
|
||||
* Scan the restore directory for ZIP files that look like backups.
|
||||
*/
|
||||
function scanForBackups(): array
|
||||
{
|
||||
$dir = RESTORE_DIR;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '/*.zip') as $path) {
|
||||
$name = basename($path);
|
||||
|
||||
/* Skip the restore script wrapper if present */
|
||||
if ($name === 'restore.php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = [
|
||||
'name' => $name,
|
||||
'path' => $path,
|
||||
'size' => filesize($path),
|
||||
'date' => date('Y-m-d H:i:s', filemtime($path)),
|
||||
];
|
||||
}
|
||||
|
||||
/* Sort by modification time, newest first */
|
||||
usort($files, fn($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle backup file selection and set the working file.
|
||||
*/
|
||||
function getSelectedBackupFile(): string
|
||||
{
|
||||
if (!empty($_POST['backup_file'])) {
|
||||
$selected = basename($_POST['backup_file']); /* sanitize — basename only */
|
||||
$path = RESTORE_DIR . '/' . $selected;
|
||||
|
||||
if (is_file($path) && str_ends_with(strtolower($selected), '.zip')) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
/* Auto-select if only one ZIP exists */
|
||||
$backups = scanForBackups();
|
||||
|
||||
if (count($backups) === 1) {
|
||||
return $backups[0]['path'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
SCANNER;
|
||||
|
||||
/* Insert scanner after the opening PHP section but before the action handlers */
|
||||
$php = str_replace(
|
||||
"/* ── Action Handlers",
|
||||
$scannerCode . "\n/* ── Action Handlers",
|
||||
$php
|
||||
);
|
||||
|
||||
/* Modify actionExtract to use getSelectedBackupFile() instead of BACKUP_FILE */
|
||||
$php = str_replace(
|
||||
'$zip->open(BACKUP_FILE)',
|
||||
'$zip->open(getSelectedBackupFile() ?: BACKUP_FILE)',
|
||||
$php
|
||||
);
|
||||
|
||||
/* Modify the pre-checks to use getSelectedBackupFile() */
|
||||
$php = str_replace(
|
||||
"file_exists(BACKUP_FILE)",
|
||||
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
|
||||
$php
|
||||
);
|
||||
|
||||
$html = self::generateFrontend();
|
||||
|
||||
/* Add backup file selector to the frontend before the extract step */
|
||||
$selectorHtml = <<<'SELECTOR'
|
||||
<!-- Backup File Selector (standalone mode) -->
|
||||
<div id="mr-step-select" class="mr-step" style="display:none;">
|
||||
<h2 class="mr-step-title">Select Backup File</h2>
|
||||
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
||||
<div id="mr-backup-list"></div>
|
||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||
var list = document.getElementById('mr-backup-list');
|
||||
var hiddenInput = document.getElementById('mr-backup-file');
|
||||
|
||||
if (backups.length === 0) {
|
||||
var alert = document.createElement('div');
|
||||
alert.className = 'mr-alert mr-alert-danger';
|
||||
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||
list.appendChild(alert);
|
||||
} else if (backups.length === 1) {
|
||||
hiddenInput.value = backups[0].name;
|
||||
var found = document.createElement('div');
|
||||
found.className = 'mr-alert mr-alert-success';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = backups[0].name;
|
||||
found.appendChild(document.createTextNode('Found: '));
|
||||
found.appendChild(strong);
|
||||
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||
list.appendChild(found);
|
||||
} else {
|
||||
var group = document.createElement('div');
|
||||
group.className = 'mr-field-group';
|
||||
backups.forEach(function(b) {
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
||||
var radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'backup_choice';
|
||||
radio.value = b.name;
|
||||
radio.style.marginRight = '8px';
|
||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||
label.appendChild(radio);
|
||||
var nameStrong = document.createElement('strong');
|
||||
nameStrong.textContent = b.name;
|
||||
label.appendChild(nameStrong);
|
||||
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
||||
group.appendChild(label);
|
||||
});
|
||||
list.appendChild(group);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
SELECTOR;
|
||||
|
||||
/* Insert the selector before the extract step in the HTML */
|
||||
$html = str_replace(
|
||||
'<!-- Step: Extract -->',
|
||||
$selectorHtml . "\n<!-- Step: Extract -->",
|
||||
$html
|
||||
);
|
||||
|
||||
return $php . $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script.
|
||||
*
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @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
|
||||
* Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup
|
||||
* directory paths and archive filename formats.
|
||||
*/
|
||||
|
||||
@@ -24,21 +24,21 @@ 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',
|
||||
'[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',
|
||||
'[DEFAULT_DIR]' => 'Default backup directory',
|
||||
'[HOME]' => 'Home directory of the PHP process owner',
|
||||
];
|
||||
@@ -62,21 +62,21 @@ class PlaceholderResolver
|
||||
}
|
||||
|
||||
$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)),
|
||||
'[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)),
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||
];
|
||||
@@ -103,7 +103,7 @@ class PlaceholderResolver
|
||||
*/
|
||||
public function getHostname(): string
|
||||
{
|
||||
return $this->replacements['[host]'];
|
||||
return $this->replacements['[HOST]'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +111,7 @@ class PlaceholderResolver
|
||||
*/
|
||||
public function getTag(): string
|
||||
{
|
||||
return $this->replacements['[datetime]'];
|
||||
return $this->replacements['[DATETIME]'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class RestoreEngine
|
||||
{
|
||||
@@ -166,6 +167,9 @@ class RestoreEngine
|
||||
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRestore(true, $recordId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Restore complete from: ' . basename($archivePath),
|
||||
@@ -185,6 +189,9 @@ class RestoreEngine
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRestore(false, $recordId);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
@@ -285,6 +292,26 @@ class RestoreEngine
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterRestore(bool $success, int $recordId): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterRestore', [
|
||||
'success' => $success,
|
||||
'record_id' => $recordId,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the restore result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -17,6 +17,7 @@ defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class SnapshotEngine
|
||||
{
|
||||
@@ -214,6 +215,9 @@ class SnapshotEngine
|
||||
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
@@ -227,6 +231,9 @@ class SnapshotEngine
|
||||
} catch (\Exception $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
||||
@@ -327,6 +334,27 @@ class SnapshotEngine
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
|
||||
'success' => $success,
|
||||
'snapshot_id' => $snapshotId,
|
||||
'content_types' => $contentTypes,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the snapshot result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class SnapshotRestoreEngine
|
||||
{
|
||||
@@ -170,6 +171,9 @@ class SnapshotRestoreEngine
|
||||
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||
@@ -185,6 +189,9 @@ class SnapshotRestoreEngine
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
@@ -537,6 +544,9 @@ class SnapshotRestoreEngine
|
||||
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
|
||||
@@ -553,6 +563,9 @@ class SnapshotRestoreEngine
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective');
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
||||
@@ -561,6 +574,27 @@ class SnapshotRestoreEngine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [
|
||||
'success' => $success,
|
||||
'snapshot_id' => $snapshotId,
|
||||
'mode' => $mode,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the restore result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -83,7 +83,7 @@ class SteppedBackupEngine
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
||||
|
||||
$session->archivePath = $backupDir . '/' . $archiveName;
|
||||
|
||||
@@ -52,15 +52,15 @@ class FolderPickerField extends FormField
|
||||
$placeholders = [
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||
'[host]' => $hostname,
|
||||
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[profile_id]' => '1',
|
||||
'[profile_name]' => 'default',
|
||||
'[type]' => 'full',
|
||||
'[year]' => date('Y'),
|
||||
'[month]' => date('m'),
|
||||
'[day]' => date('d'),
|
||||
'[date]' => date('Ymd'),
|
||||
'[HOST]' => $hostname,
|
||||
'[SITE_NAME]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[PROFILE_ID]' => '1',
|
||||
'[PROFILE_NAME]' => 'default',
|
||||
'[TYPE]' => 'full',
|
||||
'[YEAR]' => date('Y'),
|
||||
'[MONTH]' => date('m'),
|
||||
'[DAY]' => date('d'),
|
||||
'[DATE]' => date('Ymd'),
|
||||
];
|
||||
|
||||
$placeholdersJson = json_encode($placeholders);
|
||||
@@ -96,51 +96,140 @@ class FolderPickerField extends FormField
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#{$id}_helpModal" title="Available placeholders">
|
||||
<button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
|
||||
<span class="icon-question-circle" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
|
||||
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOST]" title="Server hostname">[HOST]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[SITE_NAME]" title="Joomla site name">[SITE_NAME]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DATE]" title="Date (Ymd)">[DATE]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_ID]" title="Profile ID">[PROFILE_ID]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_NAME]" title="Profile name">[PROFILE_NAME]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[TYPE]" title="Backup type">[TYPE]</button>
|
||||
</div>
|
||||
<div class="mt-1" id="{$id}_status">
|
||||
<small class="{$statusClass}">
|
||||
<span class="{$statusIcon}" aria-hidden="true"></span>
|
||||
{$statusDetail}
|
||||
</small>
|
||||
</div>
|
||||
<div class="mt-1" id="{$id}_resolved" style="font-size:0.8rem; line-height:1.6;">
|
||||
</div>
|
||||
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
||||
</div>
|
||||
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
|
||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Use these placeholders in the backup directory path. They are resolved at backup time.</p>
|
||||
|
||||
<h6 class="text-primary">How Path Resolution Works</h6>
|
||||
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Absolute Paths</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
|
||||
<ul class="mb-0">
|
||||
<li><code>/home/user/backups</code> — Fixed path on the server</li>
|
||||
<li><code>/var/backups/joomla</code> — System backup directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Relative Paths</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
|
||||
<table class="table table-sm mb-2">
|
||||
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
|
||||
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
|
||||
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
|
||||
<ul class="mb-0">
|
||||
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
|
||||
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
|
||||
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Available Placeholders</h6>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
|
||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
||||
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory (inside web root)</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
||||
<tr><td><code>[host]</code></td><td>Server hostname</td><td><code>{$placeholders['[host]']}</code></td></tr>
|
||||
<tr><td><code>[site_name]</code></td><td>Joomla site name</td><td><code>{$placeholders['[site_name]']}</code></td></tr>
|
||||
<tr><td><code>[date]</code></td><td>Date (Ymd)</td><td><code>{$placeholders['[date]']}</code></td></tr>
|
||||
<tr><td><code>[year]</code></td><td>Four-digit year</td><td><code>{$placeholders['[year]']}</code></td></tr>
|
||||
<tr><td><code>[month]</code></td><td>Two-digit month</td><td><code>{$placeholders['[month]']}</code></td></tr>
|
||||
<tr><td><code>[day]</code></td><td>Two-digit day</td><td><code>{$placeholders['[day]']}</code></td></tr>
|
||||
<tr><td><code>[profile_id]</code></td><td>Backup profile ID</td><td><code>1</code></td></tr>
|
||||
<tr><td><code>[profile_name]</code></td><td>Profile title</td><td><code>default</code></td></tr>
|
||||
<tr><td><code>[type]</code></td><td>Backup type</td><td><code>full</code></td></tr>
|
||||
<tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
||||
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
||||
<tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
|
||||
<tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
|
||||
<tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</td><td><code>{$placeholders['[DATE]']}</code></td></tr>
|
||||
<tr><td><code>[YEAR]</code></td><td>Four-digit year.</td><td><code>{$placeholders['[YEAR]']}</code></td></tr>
|
||||
<tr><td><code>[MONTH]</code></td><td>Two-digit month (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
|
||||
<tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
|
||||
<tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
|
||||
<tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
|
||||
<tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</td><td><code>full</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h6>Recommended Paths</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><code>[HOME]/backups</code> — Outside web root (recommended)</li>
|
||||
<li><code>[HOME]/backups/[host]</code> — Per-site subdirectory</li>
|
||||
<li><code>[DEFAULT_DIR]</code> — Inside web root (protected by .htaccess)</li>
|
||||
</ul>
|
||||
|
||||
<h6 class="text-primary mt-3">Recommended Configurations</h6>
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Single site, secure</strong></td>
|
||||
<td><code>[HOME]/backups</code></td>
|
||||
<td>Outside web root. Best for most sites.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Multiple sites on one server</strong></td>
|
||||
<td><code>[HOME]/backups/[HOST]</code></td>
|
||||
<td>Each site gets its own subdirectory.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Date-organized</strong></td>
|
||||
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
|
||||
<td>Backups sorted by year and month.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Per-profile</strong></td>
|
||||
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
|
||||
<td>Separate directory for each backup profile.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Shared hosting (default)</strong></td>
|
||||
<td><code>[DEFAULT_DIR]</code></td>
|
||||
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="alert alert-info py-2 mt-3 mb-0">
|
||||
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@@ -155,6 +244,56 @@ class FolderPickerField extends FormField
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
/* Clickable placeholder insertion at cursor position */
|
||||
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var target = document.getElementById(this.getAttribute('data-field'));
|
||||
var ph = this.getAttribute('data-ph');
|
||||
if (!target) return;
|
||||
var start = target.selectionStart || 0;
|
||||
var end = target.selectionEnd || 0;
|
||||
var val = target.value;
|
||||
target.value = val.substring(0, start) + ph + val.substring(end);
|
||||
/* Move cursor to after the inserted placeholder */
|
||||
var newPos = start + ph.length;
|
||||
target.setSelectionRange(newPos, newPos);
|
||||
target.focus();
|
||||
/* Trigger input event so status updates */
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
|
||||
/* Help button — open modal with Bootstrap 5 or fallback */
|
||||
var helpBtn = document.getElementById('{$id}_helpBtn');
|
||||
var helpModal = document.getElementById('{$id}_helpModal');
|
||||
if (helpBtn && helpModal) {
|
||||
helpBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
|
||||
modal.show();
|
||||
} else {
|
||||
helpModal.classList.add('show');
|
||||
helpModal.style.display = 'block';
|
||||
helpModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.id = '{$id}_backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
|
||||
helpModal.classList.remove('show');
|
||||
helpModal.style.display = 'none';
|
||||
helpModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var bd = document.getElementById('{$id}_backdrop');
|
||||
if (bd) bd.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fieldId = '{$id}';
|
||||
var btn = document.getElementById(fieldId + '_btn');
|
||||
var browser = document.getElementById(fieldId + '_browser');
|
||||
@@ -162,7 +301,7 @@ class FolderPickerField extends FormField
|
||||
var input = document.getElementById(fieldId);
|
||||
var placeholders = {$placeholdersJson};
|
||||
|
||||
// Resolve placeholders in a path (forward: [site_name] -> actual value)
|
||||
// Resolve placeholders in a path (forward: [SITE_NAME] -> actual value)
|
||||
function resolve(path) {
|
||||
for (var key in placeholders) {
|
||||
path = path.split(key).join(placeholders[key]);
|
||||
@@ -253,8 +392,54 @@ class FolderPickerField extends FormField
|
||||
});
|
||||
}
|
||||
|
||||
/* Show which placeholders are in use and their resolved values */
|
||||
var resolvedDiv = document.getElementById(fieldId + '_resolved');
|
||||
|
||||
function updateResolvedDisplay() {
|
||||
while (resolvedDiv.firstChild) resolvedDiv.removeChild(resolvedDiv.firstChild);
|
||||
var val = input.value || '';
|
||||
var found = false;
|
||||
|
||||
for (var key in placeholders) {
|
||||
if (val.indexOf(key) !== -1 && placeholders[key]) {
|
||||
found = true;
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge bg-light text-dark border me-1 mb-1';
|
||||
badge.style.fontSize = '0.75rem';
|
||||
badge.style.fontFamily = 'monospace';
|
||||
|
||||
var keySpan = document.createElement('strong');
|
||||
keySpan.textContent = key;
|
||||
badge.appendChild(keySpan);
|
||||
|
||||
badge.appendChild(document.createTextNode(' = '));
|
||||
|
||||
var valSpan = document.createElement('span');
|
||||
valSpan.className = 'text-primary';
|
||||
valSpan.textContent = placeholders[key];
|
||||
badge.appendChild(valSpan);
|
||||
|
||||
resolvedDiv.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
var fullResolved = document.createElement('div');
|
||||
fullResolved.className = 'mt-1';
|
||||
var arrow = document.createElement('span');
|
||||
arrow.className = 'text-muted';
|
||||
arrow.textContent = 'EXAMPLE: ';
|
||||
fullResolved.appendChild(arrow);
|
||||
var code = document.createElement('code');
|
||||
code.textContent = resolve(val);
|
||||
fullResolved.appendChild(code);
|
||||
resolvedDiv.appendChild(fullResolved);
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
clearTimeout(checkTimer);
|
||||
updateResolvedDisplay();
|
||||
checkTimer = setTimeout(checkDirPermissions, 400);
|
||||
});
|
||||
|
||||
@@ -368,6 +553,7 @@ class FolderPickerField extends FormField
|
||||
|
||||
// Run initial check on page load
|
||||
setDefaultDirWarning();
|
||||
updateResolvedDisplay();
|
||||
checkDirPermissions();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @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
|
||||
*
|
||||
* Text field with clickable placeholder pills that insert at cursor position.
|
||||
* Used for backup directory and archive name format fields.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
class PlaceholderTextField extends FormField
|
||||
{
|
||||
protected $type = 'PlaceholderText';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = htmlspecialchars($this->value ?? $this->default ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
$hint = htmlspecialchars($this->element['hint'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$max = (int) ($this->element['maxlength'] ?? 512);
|
||||
|
||||
$placeholderAttr = (string) ($this->element['placeholders'] ?? '');
|
||||
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
|
||||
|
||||
if (empty($placeholders)) {
|
||||
$placeholders = ['[HOST]', '[DATE]', '[DATETIME]', '[TIME]', '[YEAR]', '[MONTH]', '[DAY]',
|
||||
'[HOUR]', '[MINUTE]', '[SECOND]', '[PROFILE_ID]', '[PROFILE_NAME]', '[SITE_NAME]', '[TYPE]', '[RANDOM]'];
|
||||
}
|
||||
|
||||
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
|
||||
. ' class="form-control" maxlength="' . $max . '"'
|
||||
. ($hint ? ' placeholder="' . $hint . '"' : '') . '>';
|
||||
|
||||
$html .= '<div class="mt-1" style="display:flex; flex-wrap:wrap; gap:4px;">';
|
||||
$html .= '<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>';
|
||||
|
||||
foreach ($placeholders as $ph) {
|
||||
$html .= '<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert"'
|
||||
. ' data-field="' . $id . '" data-ph="' . htmlspecialchars($ph) . '">'
|
||||
. htmlspecialchars($ph) . '</button>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= <<<JS
|
||||
<script>
|
||||
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var target = document.getElementById(this.getAttribute('data-field'));
|
||||
var ph = this.getAttribute('data-ph');
|
||||
if (!target) return;
|
||||
var start = target.selectionStart || 0;
|
||||
var end = target.selectionEnd || 0;
|
||||
var val = target.value;
|
||||
target.value = val.substring(0, start) + ph + val.substring(end);
|
||||
var newPos = start + ph.length;
|
||||
target.setSelectionRange(newPos, newPos);
|
||||
target.focus();
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
JS;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@ class ProfilesModel extends ListModel
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
|
||||
|
||||
// Subquery: count of backup records per profile
|
||||
$subQuery = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||
->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id'));
|
||||
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count'));
|
||||
|
||||
$published = $this->getState('filter.published');
|
||||
|
||||
if (is_numeric($published)) {
|
||||
|
||||
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Toolbar\Toolbar;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
@@ -48,6 +51,27 @@ class HtmlView extends BaseHtmlView
|
||||
ToolbarHelper::save('profile.save');
|
||||
}
|
||||
|
||||
if (!$isNew) {
|
||||
$toolbar = Toolbar::getInstance();
|
||||
$profileId = (int) $this->item->id;
|
||||
|
||||
// "Run Backup Now" button — links to backup start with CSRF token
|
||||
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1');
|
||||
$toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW')
|
||||
->url($runUrl)
|
||||
->icon('icon-play')
|
||||
->buttonClass('btn btn-success');
|
||||
}
|
||||
|
||||
// "View Backups" link button
|
||||
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
|
||||
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
|
||||
->url($backupsUrl)
|
||||
->icon('icon-database')
|
||||
->buttonClass('btn btn-info');
|
||||
}
|
||||
|
||||
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<div id="mokosuitebackup-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:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
||||
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||
</div>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||
</div>
|
||||
|
||||
@@ -305,6 +305,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
<div id="mokosuitebackup-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:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
||||
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||
</div>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.multiselect');
|
||||
|
||||
@@ -45,9 +46,15 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_BACKUPS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
@@ -70,9 +77,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<td>
|
||||
<?php echo $this->escape($item->backup_type); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $item->id); ?>">
|
||||
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
|
||||
<?php echo (int) $item->backup_count; ?>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php if ($item->published == 1) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>">
|
||||
<span class="icon-play" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
|
||||
@@ -99,14 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||
<?php if (in_array('articles', $types)) : ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||
<span class="icon-search"></span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||
<span class="icon-search"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-types="<?php echo $this->escape($item->content_types); ?>"
|
||||
@@ -235,9 +233,9 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browse Snapshot Articles Modal -->
|
||||
<!-- Browse Snapshot Detail Modal -->
|
||||
<div id="mb-snapshot-browse-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); max-height:80vh; display:flex; flex-direction:column;">
|
||||
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
@@ -251,25 +249,86 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
|
||||
<div id="mb-browse-content" style="display:none;">
|
||||
<div class="mb-2">
|
||||
<label class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
|
||||
</label>
|
||||
<span class="text-muted ms-2" id="mb-browse-count"></span>
|
||||
|
||||
<!-- Bootstrap tabs -->
|
||||
<ul class="nav nav-tabs" id="mb-browse-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="mb-tab-articles-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-articles" type="button" role="tab" aria-controls="mb-tab-articles" aria-selected="true">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-articles-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="mb-tab-categories-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-categories" type="button" role="tab" aria-controls="mb-tab-categories" aria-selected="false">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-categories-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="mb-tab-modules-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-modules" type="button" role="tab" aria-controls="mb-tab-modules" aria-selected="false">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-modules-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content pt-3" id="mb-browse-tabs-content">
|
||||
|
||||
<!-- Articles tab -->
|
||||
<div class="tab-pane fade show active" id="mb-tab-articles" role="tabpanel" aria-labelledby="mb-tab-articles-btn">
|
||||
<div class="mb-2">
|
||||
<label class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
|
||||
</label>
|
||||
<span class="text-muted ms-2" id="mb-browse-count"></span>
|
||||
</div>
|
||||
<table class="table table-sm table-striped" id="mb-browse-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1"></th>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Categories tab -->
|
||||
<div class="tab-pane fade" id="mb-tab-categories" role="tabpanel" aria-labelledby="mb-tab-categories-btn">
|
||||
<table class="table table-sm table-striped" id="mb-browse-categories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_LEVEL'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-categories-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modules tab -->
|
||||
<div class="tab-pane fade" id="mb-tab-modules" role="tabpanel" aria-labelledby="mb-tab-modules-btn">
|
||||
<table class="table table-sm table-striped" id="mb-browse-modules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_POSITION'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-modules-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<table class="table table-sm table-striped" id="mb-browse-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1"></th>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
|
||||
@@ -344,7 +403,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
var label = document.createElement('label');
|
||||
label.className = 'form-check-label';
|
||||
label.setAttribute('for', 'mb-rtype-' + type);
|
||||
label.textContent = typeLabels[type] || type;
|
||||
label.textContent = typeLabels[TYPE] || type;
|
||||
|
||||
div.appendChild(input);
|
||||
div.appendChild(label);
|
||||
@@ -388,9 +447,16 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
document.getElementById('mb-browse-restore-btn').disabled = true;
|
||||
document.getElementById('mb-browse-select-all').checked = false;
|
||||
|
||||
// Reset to Articles tab
|
||||
var firstTab = document.querySelector('#mb-tab-articles-btn');
|
||||
if (firstTab && typeof bootstrap !== 'undefined') {
|
||||
var tab = new bootstrap.Tab(firstTab);
|
||||
tab.show();
|
||||
}
|
||||
|
||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
||||
|
||||
// Fetch articles via AJAX
|
||||
// Fetch snapshot content via AJAX
|
||||
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
||||
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
|
||||
|
||||
@@ -405,13 +471,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
return;
|
||||
}
|
||||
|
||||
var tbody = document.getElementById('mb-browse-tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
||||
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
||||
|
||||
data.articles.forEach(function(article) {
|
||||
// --- Articles ---
|
||||
var tbody = document.getElementById('mb-browse-tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
(data.articles || []).forEach(function(article) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdCheck = document.createElement('td');
|
||||
@@ -445,12 +512,84 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
|
||||
document.getElementById('mb-browse-count').textContent = data.total_articles + ' article(s)';
|
||||
document.getElementById('mb-tab-articles-count').textContent = data.total_articles;
|
||||
|
||||
// --- Categories ---
|
||||
var catTbody = document.getElementById('mb-browse-categories-tbody');
|
||||
while (catTbody.firstChild) catTbody.removeChild(catTbody.firstChild);
|
||||
|
||||
(data.categories || []).forEach(function(cat) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = cat.id;
|
||||
tr.appendChild(tdId);
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = '\u2003'.repeat(Math.max(0, cat.level - 1)) + cat.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
var tdExt = document.createElement('td');
|
||||
tdExt.textContent = cat.extension;
|
||||
tr.appendChild(tdExt);
|
||||
|
||||
var tdState = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (stateBadges[String(cat.published)] || 'bg-secondary');
|
||||
badge.textContent = stateLabels[String(cat.published)] || 'Unknown';
|
||||
tdState.appendChild(badge);
|
||||
tr.appendChild(tdState);
|
||||
|
||||
var tdLevel = document.createElement('td');
|
||||
tdLevel.textContent = cat.level;
|
||||
tr.appendChild(tdLevel);
|
||||
|
||||
catTbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-tab-categories-count').textContent = data.total_categories;
|
||||
|
||||
// --- Modules ---
|
||||
var modTbody = document.getElementById('mb-browse-modules-tbody');
|
||||
while (modTbody.firstChild) modTbody.removeChild(modTbody.firstChild);
|
||||
|
||||
(data.modules || []).forEach(function(mod) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = mod.id;
|
||||
tr.appendChild(tdId);
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = mod.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
var tdType = document.createElement('td');
|
||||
tdType.textContent = mod.module;
|
||||
tr.appendChild(tdType);
|
||||
|
||||
var tdPos = document.createElement('td');
|
||||
tdPos.textContent = mod.position;
|
||||
tr.appendChild(tdPos);
|
||||
|
||||
var tdState = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (stateBadges[String(mod.published)] || 'bg-secondary');
|
||||
badge.textContent = stateLabels[String(mod.published)] || 'Unknown';
|
||||
tdState.appendChild(badge);
|
||||
tr.appendChild(tdState);
|
||||
|
||||
modTbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-tab-modules-count').textContent = data.total_modules;
|
||||
|
||||
document.getElementById('mb-browse-content').style.display = 'block';
|
||||
})
|
||||
.catch(function(err) {
|
||||
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
|
||||
document.getElementById('mb-browse-error').textContent = 'Failed to load snapshot content: ' + err.message;
|
||||
document.getElementById('mb-browse-error').style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||
|
||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+92
-1
@@ -27,7 +27,10 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
||||
return [
|
||||
'onContentAfterSave' => 'onContentAfterSave',
|
||||
'onContentAfterDelete' => 'onContentAfterDelete',
|
||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||
'onMokoSuiteBackupAfterRestore' => 'onMokoSuiteBackupAfterRestore',
|
||||
'onMokoSuiteBackupAfterSnapshot' => 'onMokoSuiteBackupAfterSnapshot',
|
||||
'onMokoSuiteBackupAfterSnapshotRestore' => 'onMokoSuiteBackupAfterSnapshotRestore',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -130,6 +133,94 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a backup is restored.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterRestore(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$recordId = $args['record_id'] ?? 0;
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $recordId,
|
||||
'title' => 'Backup #' . $recordId,
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.backup',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a content snapshot is created.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterSnapshot(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||
$contentTypes = $args['content_types'] ?? [];
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $snapshotId,
|
||||
'title' => 'Snapshot #' . $snapshotId,
|
||||
'content_types' => implode(', ', $contentTypes),
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.snapshot',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a snapshot is restored.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterSnapshotRestore(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||
$mode = $args['mode'] ?? 'replace';
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $snapshotId,
|
||||
'title' => 'Snapshot #' . $snapshotId,
|
||||
'mode' => $mode,
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.snapshot',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an action log entry.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>Quick Icon - MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.35.04</version>
|
||||
<version>01.39.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user