feat: Content snapshots, restore UI, and config hardening (v01.25.00) #69
@@ -60,3 +60,4 @@ CODE_OF_CONDUCT.md export-ignore
|
||||
Makefile export-ignore
|
||||
composer.json export-ignore
|
||||
phpstan.neon export-ignore
|
||||
*.yml text eol=lf
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.00.00
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.25.02
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+534
-534
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.25.00 -->
|
||||
<!-- VERSION: 01.25.02 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.25.00</version>
|
||||
<version>01.25.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
<action name="mokosuitebackup.backup.run" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN" />
|
||||
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
||||
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
||||
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
||||
</section>
|
||||
</access>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
type="FolderPicker"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC"
|
||||
default="administrator/components/com_mokosuitebackup/backups"
|
||||
default="[DEFAULT_DIR]"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
@@ -21,10 +21,10 @@
|
||||
type="sql"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
|
||||
query="SELECT id AS value, title AS text FROM #__mokosuitebackup_profiles WHERE published = 1 ORDER BY ordering ASC"
|
||||
query="SELECT id AS value, CONCAT(title, ' (#', id, ')') AS text FROM #__mokosuitebackup_profiles WHERE published = 1 ORDER BY ordering ASC"
|
||||
default="1"
|
||||
>
|
||||
<option value="1">Default Backup Profile</option>
|
||||
<option value="1">Default Backup Profile (#1)</option>
|
||||
</field>
|
||||
<field
|
||||
name="show_update_notice"
|
||||
@@ -42,12 +42,13 @@
|
||||
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
|
||||
<field
|
||||
name="webcron_secret"
|
||||
type="text"
|
||||
type="WebcronSecret"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
maxlength="64"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="webcron_enabled"
|
||||
@@ -62,12 +63,12 @@
|
||||
</field>
|
||||
<field
|
||||
name="webcron_ip_whitelist"
|
||||
type="text"
|
||||
type="IpWhitelist"
|
||||
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP"
|
||||
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
hint="Leave blank to allow any IP"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -299,6 +299,64 @@ COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store bac
|
||||
|
||||
COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
|
||||
|
||||
; Restore modal
|
||||
COM_MOKOJOOMBACKUP_RESTORE_FILES="Restore files"
|
||||
COM_MOKOJOOMBACKUP_RESTORE_DATABASE="Restore database"
|
||||
COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG="Preserve current configuration.php"
|
||||
COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC="Keep your current database credentials and site paths. Recommended unless you know the backup has the correct credentials."
|
||||
COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER="Leave blank if archive is not encrypted"
|
||||
|
||||
; Snapshots
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS="Content Snapshots"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE="Content Snapshots"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_TABLE_CAPTION="Table of content snapshots"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_NONE="No snapshots found. Click 'Create Snapshot' to save a snapshot of your content."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE="Create Snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE="Restore Snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES="Select content to snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER="e.g. Before redesign, Pre-migration"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CONTENT_TYPES="Content Types"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES="Articles"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC="All articles, frontpage settings, workflow state, and tags"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES="Categories"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC="Content categories (com_content)"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES="Modules"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC="All modules and their menu assignments"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES="Please select at least one content type to snapshot."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD="No snapshot selected."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE="Restore Mode"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE="Replace (clean)"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC="Remove all existing content of the selected types and replace with snapshot data. This gives you an exact copy of the snapshot state."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE="Merge (upsert)"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC="Update existing items by ID and insert missing ones. Content added after the snapshot is preserved."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES="Types to restore"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING="Replace mode will delete all current content of the selected types before restoring from the snapshot. This cannot be undone."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
||||
|
||||
; Snapshot ACL
|
||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
|
||||
|
||||
; Webcron secret field
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_GENERATE="Generate"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE="No secret set — webcron is disabled."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT="Too short — minimum %d characters required."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK="Weak — avoid common words like 'password', 'admin', 'secret'."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK="Acceptable — consider making it longer for better security."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG="Strong secret word."
|
||||
|
||||
; IP whitelist field
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP="Your current IP"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_ADD_CURRENT_IP="Add my IP"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED="Included"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS="IP Address"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE="Remove"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger webcron (if secret is correct)."
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
||||
|
||||
; 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."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.25.00</version>
|
||||
<version>01.25.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -45,6 +45,9 @@
|
||||
<menu link="option=com_mokosuitebackup&view=backups"
|
||||
img="class:database"
|
||||
alt="Backups">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
|
||||
<menu link="option=com_mokosuitebackup&view=snapshots"
|
||||
img="class:camera"
|
||||
alt="Snapshots">COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS</menu>
|
||||
<menu link="option=com_mokosuitebackup&view=profiles"
|
||||
img="class:cog"
|
||||
alt="Profiles">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
|
||||
|
||||
@@ -78,6 +78,23 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` (
|
||||
KEY `idx_backupstart` (`backupstart`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail',
|
||||
`articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file',
|
||||
`data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes',
|
||||
`log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log',
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
||||
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
||||
`id`, `title`, `description`, `backup_type`,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail',
|
||||
`articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file',
|
||||
`data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes',
|
||||
`log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log',
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,179 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
||||
|
||||
class SnapshotsController extends AdminController
|
||||
{
|
||||
protected $text_prefix = 'COM_MOKOJOOMBACKUP_SNAPSHOTS';
|
||||
|
||||
public function getModel($name = 'Snapshot', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new content snapshot.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$contentTypes = $this->input->get('content_types', [], 'array');
|
||||
$description = $this->input->getString('description', '');
|
||||
|
||||
if (empty($contentTypes)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SnapshotEngine();
|
||||
$result = $engine->create($contentTypes, $description);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a content snapshot.
|
||||
*/
|
||||
public function restore(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
$mode = $this->input->getCmd('restore_mode', 'replace');
|
||||
$contentTypes = $this->input->get('restore_types', [], 'array');
|
||||
|
||||
// Enforce valid restore mode at controller boundary
|
||||
if (!in_array($mode, ['replace', 'merge'], true)) {
|
||||
$mode = 'replace';
|
||||
}
|
||||
|
||||
if (!$id) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SnapshotRestoreEngine();
|
||||
$result = $engine->restore($id, $mode, $contentTypes);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete snapshot records and their data files.
|
||||
*/
|
||||
public function delete(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cid = $this->input->get('cid', [], 'array');
|
||||
|
||||
if (empty($cid)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$deleted = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($cid as $id) {
|
||||
$id = (int) $id;
|
||||
|
||||
try {
|
||||
// Load record to get file path
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('data_file'))
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$dataFile = $db->loadResult();
|
||||
|
||||
// Delete data file
|
||||
if ($dataFile && is_file($dataFile)) {
|
||||
if (!unlink($dataFile)) {
|
||||
error_log('MokoSuiteBackup: Failed to delete snapshot file: ' . $dataFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete record
|
||||
$query = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
$deleted++;
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: Failed to delete snapshot ' . $id . ': ' . $e->getMessage());
|
||||
$errors[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->setMessage(
|
||||
Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted)
|
||||
. ' ' . Text::sprintf('COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS', implode(', ', $errors)),
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
$this->setMessage(Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted));
|
||||
}
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?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
|
||||
*
|
||||
* Snapshot engine — creates lightweight JSON snapshots of specific content
|
||||
* types (articles, categories, modules) without touching the filesystem.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class SnapshotEngine
|
||||
{
|
||||
private array $log = [];
|
||||
|
||||
/** Content type => tables mapping */
|
||||
private const TYPE_TABLES = [
|
||||
'articles' => [
|
||||
'#__content',
|
||||
'#__content_frontpage',
|
||||
],
|
||||
'categories' => [
|
||||
'#__categories',
|
||||
],
|
||||
'modules' => [
|
||||
'#__modules',
|
||||
'#__modules_menu',
|
||||
],
|
||||
];
|
||||
|
||||
/** Related tables always captured when articles are included */
|
||||
private const ARTICLE_RELATED = [
|
||||
'#__workflow_associations',
|
||||
'#__contentitem_tag_map',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a snapshot of selected content types.
|
||||
*
|
||||
* @param array $contentTypes Types to snapshot: articles, categories, modules
|
||||
* @param string $description User-provided description
|
||||
*
|
||||
* @return array{success: bool, message: string, id?: int}
|
||||
*/
|
||||
public function create(array $contentTypes, string $description = ''): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
if (empty($contentTypes)) {
|
||||
return ['success' => false, 'message' => 'No content types selected'];
|
||||
}
|
||||
|
||||
$validTypes = array_intersect($contentTypes, ['articles', 'categories', 'modules']);
|
||||
|
||||
if (empty($validTypes)) {
|
||||
return ['success' => false, 'message' => 'No valid content types selected'];
|
||||
}
|
||||
|
||||
$this->log('Starting snapshot: ' . implode(', ', $validTypes));
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'version' => 1,
|
||||
'created' => date('Y-m-d H:i:s'),
|
||||
'content_types' => array_values($validTypes),
|
||||
'tables' => [],
|
||||
];
|
||||
|
||||
$counts = [
|
||||
'articles' => 0,
|
||||
'categories' => 0,
|
||||
'modules' => 0,
|
||||
];
|
||||
|
||||
// Dump each selected content type
|
||||
foreach ($validTypes as $type) {
|
||||
foreach (self::TYPE_TABLES[$type] as $abstractTable) {
|
||||
$realTable = str_replace('#__', $prefix, $abstractTable);
|
||||
$rows = $this->dumpTable($db, $realTable, $abstractTable, $type);
|
||||
$data['tables'][$abstractTable] = $rows;
|
||||
$this->log(' ' . $abstractTable . ': ' . count($rows) . ' rows');
|
||||
}
|
||||
}
|
||||
|
||||
// Capture related tables for articles
|
||||
if (in_array('articles', $validTypes)) {
|
||||
$rows = $this->dumpFilteredTable(
|
||||
$db,
|
||||
str_replace('#__', $prefix, '#__workflow_associations'),
|
||||
'#__workflow_associations',
|
||||
'extension',
|
||||
'com_content.article'
|
||||
);
|
||||
$data['tables']['#__workflow_associations'] = $rows;
|
||||
$this->log(' #__workflow_associations: ' . count($rows) . ' rows');
|
||||
|
||||
$rows = $this->dumpTagMap($db, $prefix);
|
||||
$data['tables']['#__contentitem_tag_map'] = $rows;
|
||||
$this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows');
|
||||
}
|
||||
|
||||
// Count items
|
||||
if (in_array('articles', $validTypes)) {
|
||||
$counts['articles'] = count($data['tables']['#__content'] ?? []);
|
||||
}
|
||||
|
||||
if (in_array('categories', $validTypes)) {
|
||||
$counts['categories'] = count($data['tables']['#__categories'] ?? []);
|
||||
}
|
||||
|
||||
if (in_array('modules', $validTypes)) {
|
||||
$counts['modules'] = count($data['tables']['#__modules'] ?? []);
|
||||
}
|
||||
|
||||
// Write JSON file to backup directory
|
||||
$backupDir = BackupDirectory::getDefaultAbsolute();
|
||||
BackupDirectory::ensureReady($backupDir);
|
||||
|
||||
$filename = 'snapshot_' . date('Ymd_His') . '_' . implode('-', $validTypes) . '.json';
|
||||
$filePath = $backupDir . '/' . $filename;
|
||||
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Failed to encode snapshot data as JSON');
|
||||
}
|
||||
|
||||
if (file_put_contents($filePath, $json) === false) {
|
||||
throw new \RuntimeException('Failed to write snapshot file: ' . $filePath);
|
||||
}
|
||||
|
||||
$fileSize = strlen($json);
|
||||
$this->log('Snapshot saved: ' . $filename . ' (' . number_format($fileSize) . ' bytes)');
|
||||
|
||||
// Create database record
|
||||
$now = Factory::getDate()->toSql();
|
||||
$userId = Factory::getApplication()->getIdentity()->id ?? 0;
|
||||
|
||||
$record = (object) [
|
||||
'description' => $description ?: 'Snapshot: ' . implode(', ', $validTypes),
|
||||
'content_types' => json_encode(array_values($validTypes)),
|
||||
'status' => 'complete',
|
||||
'articles_count' => $counts['articles'],
|
||||
'categories_count' => $counts['categories'],
|
||||
'modules_count' => $counts['modules'],
|
||||
'data_file' => $filePath,
|
||||
'data_size' => $fileSize,
|
||||
'log' => implode("\n", $this->log),
|
||||
'created' => $now,
|
||||
'created_by' => $userId,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuitebackup_snapshots', $record, 'id');
|
||||
|
||||
$this->log('Snapshot record created: ID ' . $record->id);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
'Snapshot created: %d articles, %d categories, %d modules',
|
||||
$counts['articles'],
|
||||
$counts['categories'],
|
||||
$counts['modules']
|
||||
),
|
||||
'id' => $record->id,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump all rows from a table.
|
||||
*/
|
||||
private function dumpTable(object $db, string $realTable, string $abstractTable, string $type): array
|
||||
{
|
||||
$query = $db->getQuery(true)->select('*')->from($db->quoteName($realTable));
|
||||
|
||||
// Filter categories to com_content only
|
||||
if ($abstractTable === '#__categories' && $type === 'categories') {
|
||||
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump rows from a table filtered by a column value.
|
||||
*/
|
||||
private function dumpFilteredTable(object $db, string $realTable, string $abstractTable, string $column, string $value): array
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($realTable))
|
||||
->where($db->quoteName($column) . ' = ' . $db->quote($value));
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump tag map entries for com_content items.
|
||||
*/
|
||||
private function dumpTagMap(object $db, string $prefix): array
|
||||
{
|
||||
$table = $prefix . 'contentitem_tag_map';
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($table))
|
||||
->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%'));
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
<?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
|
||||
*
|
||||
* Restores content from a snapshot JSON file.
|
||||
*
|
||||
* Two restore modes:
|
||||
* - replace: Truncates target tables then inserts all snapshot rows (clean slate)
|
||||
* - merge: Upserts by primary key — updates existing rows, inserts new ones
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class SnapshotRestoreEngine
|
||||
{
|
||||
private array $log = [];
|
||||
|
||||
/** Primary key columns for each table */
|
||||
private const PRIMARY_KEYS = [
|
||||
'#__content' => 'id',
|
||||
'#__content_frontpage' => 'content_id',
|
||||
'#__categories' => 'id',
|
||||
'#__workflow_associations' => 'item_id',
|
||||
'#__contentitem_tag_map' => null, // composite key, handled specially
|
||||
'#__modules' => 'id',
|
||||
'#__modules_menu' => null, // composite key, handled specially
|
||||
];
|
||||
|
||||
/**
|
||||
* Restore from a snapshot record.
|
||||
*
|
||||
* @param int $snapshotId Snapshot record ID
|
||||
* @param string $mode 'replace' or 'merge'
|
||||
* @param array $contentTypes Which types to restore (empty = all from snapshot)
|
||||
*
|
||||
* @return array{success: bool, message: string, log?: string}
|
||||
*/
|
||||
public function restore(int $snapshotId, string $mode = 'replace', array $contentTypes = []): array
|
||||
{
|
||||
if (!@set_time_limit(0)) {
|
||||
$this->log('WARNING: Could not disable time limit — large restores may timeout');
|
||||
}
|
||||
|
||||
if (!@ini_set('memory_limit', '512M')) {
|
||||
$this->log('WARNING: Could not increase memory limit to 512M');
|
||||
}
|
||||
|
||||
if (!in_array($mode, ['replace', 'merge'])) {
|
||||
return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load snapshot record
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . $snapshotId);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId];
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
return ['success' => false, 'message' => 'Cannot restore from failed snapshot'];
|
||||
}
|
||||
|
||||
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||
return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file];
|
||||
}
|
||||
|
||||
$this->log('Loading snapshot file: ' . basename($record->data_file));
|
||||
|
||||
$json = file_get_contents($record->data_file);
|
||||
|
||||
if ($json === false) {
|
||||
return ['success' => false, 'message' => 'Cannot read snapshot file'];
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()];
|
||||
}
|
||||
|
||||
if (!is_array($data) || empty($data['tables'])) {
|
||||
return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key'];
|
||||
}
|
||||
|
||||
$snapshotTypes = $data['content_types'] ?? [];
|
||||
$this->log('Snapshot contains: ' . implode(', ', $snapshotTypes));
|
||||
$this->log('Restore mode: ' . $mode);
|
||||
|
||||
// Determine which types to restore
|
||||
if (!empty($contentTypes)) {
|
||||
$restoreTypes = array_intersect($contentTypes, $snapshotTypes);
|
||||
} else {
|
||||
$restoreTypes = $snapshotTypes;
|
||||
}
|
||||
|
||||
if (empty($restoreTypes)) {
|
||||
return ['success' => false, 'message' => 'No matching content types to restore'];
|
||||
}
|
||||
|
||||
$this->log('Restoring types: ' . implode(', ', $restoreTypes));
|
||||
|
||||
$prefix = $db->getPrefix();
|
||||
$totalRows = 0;
|
||||
|
||||
try {
|
||||
$db->transactionStart();
|
||||
|
||||
// Build list of tables to restore based on selected types
|
||||
$tablesToRestore = $this->getTablesToRestore($restoreTypes);
|
||||
|
||||
foreach ($tablesToRestore as $abstractTable) {
|
||||
if (!isset($data['tables'][$abstractTable])) {
|
||||
$this->log(' Skipping ' . $abstractTable . ' (not in snapshot)');
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $data['tables'][$abstractTable];
|
||||
$realTable = str_replace('#__', $prefix, $abstractTable);
|
||||
|
||||
if ($mode === 'replace') {
|
||||
$rowCount = $this->restoreReplace($db, $realTable, $abstractTable, $rows);
|
||||
} else {
|
||||
$rowCount = $this->restoreMerge($db, $realTable, $abstractTable, $rows);
|
||||
}
|
||||
|
||||
$totalRows += $rowCount;
|
||||
$this->log(' ' . $abstractTable . ': ' . $rowCount . ' rows restored');
|
||||
}
|
||||
|
||||
$db->transactionCommit();
|
||||
|
||||
$this->log('Restore complete: ' . $totalRows . ' total rows');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$db->transactionRollback();
|
||||
$this->log('Transaction rolled back');
|
||||
} catch (\Exception $rollbackEx) {
|
||||
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
|
||||
}
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace mode: delete existing rows, then insert all snapshot rows.
|
||||
*/
|
||||
private function restoreReplace(object $db, string $realTable, string $abstractTable, array $rows): int
|
||||
{
|
||||
// Use DELETE instead of TRUNCATE to stay within transaction
|
||||
$this->truncateFiltered($db, $realTable, $abstractTable, $rows);
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$obj = (object) $row;
|
||||
$db->insertObject($realTable, $obj);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge mode: upsert rows by primary key.
|
||||
*/
|
||||
private function restoreMerge(object $db, string $realTable, string $abstractTable, array $rows): int
|
||||
{
|
||||
$pk = self::PRIMARY_KEYS[$abstractTable] ?? null;
|
||||
$count = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$obj = (object) $row;
|
||||
|
||||
if ($pk !== null && isset($row[$pk])) {
|
||||
// Check if row exists
|
||||
$exists = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName($realTable))
|
||||
->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk]))
|
||||
)->loadResult();
|
||||
|
||||
if ($exists) {
|
||||
$db->updateObject($realTable, $obj, $pk);
|
||||
} else {
|
||||
$db->insertObject($realTable, $obj);
|
||||
}
|
||||
} else {
|
||||
// Composite key tables — insert, skip genuine duplicates
|
||||
try {
|
||||
$db->insertObject($realTable, $obj);
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), 'Duplicate entry') || $e->getCode() === 1062) {
|
||||
$this->log(' Skipped duplicate in ' . $abstractTable);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rows from a table, scoping to relevant content only.
|
||||
*
|
||||
* Shared tables (#__categories, #__modules, etc.) are filtered so
|
||||
* only the rows belonging to our content types are deleted — never
|
||||
* the entire table.
|
||||
*/
|
||||
private function truncateFiltered(object $db, string $realTable, string $abstractTable, array $rows): void
|
||||
{
|
||||
$query = $db->getQuery(true)->delete($db->quoteName($realTable));
|
||||
|
||||
switch ($abstractTable) {
|
||||
case '#__categories':
|
||||
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'));
|
||||
break;
|
||||
|
||||
case '#__workflow_associations':
|
||||
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content.article'));
|
||||
break;
|
||||
|
||||
case '#__contentitem_tag_map':
|
||||
$query->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%'));
|
||||
break;
|
||||
|
||||
case '#__modules':
|
||||
// Only delete modules that exist in the snapshot — never wipe all site modules
|
||||
$ids = array_filter(array_column($rows, 'id'));
|
||||
|
||||
if (empty($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = array_map('intval', $ids);
|
||||
$query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')');
|
||||
break;
|
||||
|
||||
case '#__modules_menu':
|
||||
// Only delete menu assignments for modules in the snapshot
|
||||
$moduleIds = array_filter(array_column($rows, 'moduleid'));
|
||||
|
||||
if (empty($moduleIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleIds = array_map('intval', array_unique($moduleIds));
|
||||
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
|
||||
break;
|
||||
|
||||
// #__content and #__content_frontpage are fully owned by com_content
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of abstract table names for the given content types.
|
||||
*/
|
||||
private function getTablesToRestore(array $types): array
|
||||
{
|
||||
$tables = [];
|
||||
|
||||
if (in_array('articles', $types)) {
|
||||
$tables[] = '#__content';
|
||||
$tables[] = '#__content_frontpage';
|
||||
$tables[] = '#__workflow_associations';
|
||||
$tables[] = '#__contentitem_tag_map';
|
||||
}
|
||||
|
||||
if (in_array('categories', $types)) {
|
||||
$tables[] = '#__categories';
|
||||
}
|
||||
|
||||
if (in_array('modules', $types)) {
|
||||
$tables[] = '#__modules';
|
||||
$tables[] = '#__modules_menu';
|
||||
}
|
||||
|
||||
return array_unique($tables);
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?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
|
||||
*
|
||||
* Custom field for IP whitelist management.
|
||||
* Shows current user's IP and presents entries as a table.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class IpWhitelistField extends FormField
|
||||
{
|
||||
protected $type = 'IpWhitelist';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = trim($this->value ?? '');
|
||||
$id = $this->id;
|
||||
$name = $this->name;
|
||||
$currentIp = $this->getCurrentIp();
|
||||
|
||||
$ips = array_filter(array_map('trim', explode(',', $value)));
|
||||
|
||||
$html = '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||
. ' value="' . htmlspecialchars($value) . '">';
|
||||
|
||||
// Current IP display
|
||||
$html .= '<div class="alert alert-info py-2 mb-2">'
|
||||
. '<span class="icon-location" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP') . ': '
|
||||
. '<strong class="font-monospace">' . htmlspecialchars($currentIp) . '</strong>';
|
||||
|
||||
$alreadyAdded = in_array($currentIp, $ips);
|
||||
if (!$alreadyAdded) {
|
||||
$html .= ' <button type="button" class="btn btn-sm btn-outline-primary ms-2"'
|
||||
. ' onclick="mokoIpAdd(\'' . htmlspecialchars($id) . '\', \'' . htmlspecialchars($currentIp) . '\')">'
|
||||
. '<span class="icon-plus" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_ADD_CURRENT_IP')
|
||||
. '</button>';
|
||||
} else {
|
||||
$html .= ' <span class="badge bg-success ms-2">' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED') . '</span>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
// IP table
|
||||
$html .= '<table class="table table-sm table-bordered" id="' . htmlspecialchars($id) . '-table">';
|
||||
$html .= '<thead><tr>'
|
||||
. '<th>' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS') . '</th>'
|
||||
. '<th class="w-10 text-center">' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE') . '</th>'
|
||||
. '</tr></thead>';
|
||||
$html .= '<tbody>';
|
||||
|
||||
if (empty($ips)) {
|
||||
$html .= '<tr class="moko-ip-empty"><td colspan="2" class="text-muted text-center">'
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE')
|
||||
. '</td></tr>';
|
||||
} else {
|
||||
foreach ($ips as $ip) {
|
||||
$html .= '<tr>'
|
||||
. '<td class="font-monospace">' . htmlspecialchars($ip) . '</td>'
|
||||
. '<td class="text-center">'
|
||||
. '<button type="button" class="btn btn-sm btn-outline-danger"'
|
||||
. ' onclick="mokoIpRemove(\'' . htmlspecialchars($id) . '\', \'' . htmlspecialchars($ip) . '\')">'
|
||||
. '<span class="icon-times" aria-hidden="true"></span>'
|
||||
. '</button></td>'
|
||||
. '</tr>';
|
||||
}
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
|
||||
// Add custom IP
|
||||
$html .= '<div class="input-group input-group-sm" style="max-width:350px;">';
|
||||
$html .= '<input type="text" class="form-control font-monospace" id="' . htmlspecialchars($id) . '-new"'
|
||||
. ' placeholder="' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER') . '"'
|
||||
. ' pattern="[0-9a-fA-F.:\/]+">';
|
||||
$html .= '<button type="button" class="btn btn-outline-secondary"'
|
||||
. ' onclick="mokoIpAddCustom(\'' . htmlspecialchars($id) . '\')">'
|
||||
. '<span class="icon-plus" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD')
|
||||
. '</button>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= $this->getScript();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function getCurrentIp(): string
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
// Try standard header first, then forwarded headers
|
||||
$ip = $app->input->server->getString('REMOTE_ADDR', '');
|
||||
|
||||
// Check forwarded headers (common behind reverse proxies)
|
||||
$forwarded = $app->input->server->getString('HTTP_X_FORWARDED_FOR', '');
|
||||
|
||||
if (!empty($forwarded)) {
|
||||
// Take the first IP in the chain (client IP)
|
||||
$parts = explode(',', $forwarded);
|
||||
$candidate = trim($parts[0]);
|
||||
|
||||
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
|
||||
$ip = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $ip ?: '0.0.0.0';
|
||||
}
|
||||
|
||||
private function getScript(): string
|
||||
{
|
||||
$noneText = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE'));
|
||||
|
||||
return <<<JS
|
||||
<script>
|
||||
function mokoIpGetList(fieldId) {
|
||||
var val = document.getElementById(fieldId).value.trim();
|
||||
return val ? val.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function mokoIpSync(fieldId, ips) {
|
||||
document.getElementById(fieldId).value = ips.join(', ');
|
||||
mokoIpRebuildTable(fieldId, ips);
|
||||
}
|
||||
|
||||
function mokoIpRebuildTable(fieldId, ips) {
|
||||
var tbody = document.querySelector('#' + fieldId + '-table tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
if (ips.length === 0) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'moko-ip-empty';
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.className = 'text-muted text-center';
|
||||
td.textContent = {$noneText};
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
ips.forEach(function(ip) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdIp = document.createElement('td');
|
||||
tdIp.className = 'font-monospace';
|
||||
tdIp.textContent = ip;
|
||||
tr.appendChild(tdIp);
|
||||
|
||||
var tdAct = document.createElement('td');
|
||||
tdAct.className = 'text-center';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-danger';
|
||||
btn.onclick = function() { mokoIpRemove(fieldId, ip); };
|
||||
var span = document.createElement('span');
|
||||
span.className = 'icon-times';
|
||||
btn.appendChild(span);
|
||||
tdAct.appendChild(btn);
|
||||
tr.appendChild(tdAct);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function mokoIpAdd(fieldId, ip) {
|
||||
var ips = mokoIpGetList(fieldId);
|
||||
if (ips.indexOf(ip) === -1) {
|
||||
ips.push(ip);
|
||||
mokoIpSync(fieldId, ips);
|
||||
}
|
||||
}
|
||||
|
||||
function mokoIpRemove(fieldId, ip) {
|
||||
var ips = mokoIpGetList(fieldId).filter(function(i) { return i !== ip; });
|
||||
mokoIpSync(fieldId, ips);
|
||||
}
|
||||
|
||||
function mokoIpAddCustom(fieldId) {
|
||||
var input = document.getElementById(fieldId + '-new');
|
||||
var ip = input.value.trim();
|
||||
if (!ip) return;
|
||||
mokoIpAdd(fieldId, ip);
|
||||
input.value = '';
|
||||
}
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?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
|
||||
*
|
||||
* Custom field for the webcron secret word.
|
||||
* Generates a random default and validates minimum strength.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class WebcronSecretField extends FormField
|
||||
{
|
||||
protected $type = 'WebcronSecret';
|
||||
|
||||
private const MIN_LENGTH = 16;
|
||||
private const WEAK_PATTERNS = [
|
||||
'password', 'secret', '123456', 'admin', 'backup',
|
||||
'test', 'webcron', 'qwerty', 'letmein', 'welcome',
|
||||
];
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = $this->value ?? '';
|
||||
$id = $this->id;
|
||||
$name = $this->name;
|
||||
$maxLength = (int) ($this->element['maxlength'] ?? 64);
|
||||
|
||||
$strengthHtml = '';
|
||||
$strengthClass = 'text-muted';
|
||||
$strengthText = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
|
||||
|
||||
if (!empty($value)) {
|
||||
$strength = $this->evaluateStrength($value);
|
||||
$strengthClass = $strength['class'];
|
||||
$strengthText = $strength['label'];
|
||||
}
|
||||
|
||||
$html = '<div class="input-group">';
|
||||
$html .= '<input type="text" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||
. ' value="' . htmlspecialchars($value) . '"'
|
||||
. ' class="form-control" maxlength="' . $maxLength . '"'
|
||||
. ' autocomplete="off" spellcheck="false"'
|
||||
. ' onchange="mokoWebcronCheckStrength(this)"'
|
||||
. ' onkeyup="mokoWebcronCheckStrength(this)">';
|
||||
$html .= '<button type="button" class="btn btn-outline-secondary" onclick="mokoWebcronGenerate(\'' . htmlspecialchars($id) . '\')"'
|
||||
. ' title="' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE') . '">'
|
||||
. '<span class="icon-refresh" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE')
|
||||
. '</button>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div id="' . htmlspecialchars($id) . '-strength" class="small mt-1 ' . $strengthClass . '">'
|
||||
. $strengthText . '</div>';
|
||||
|
||||
$html .= $this->getScript();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function evaluateStrength(string $value): array
|
||||
{
|
||||
$len = strlen($value);
|
||||
|
||||
// Check weak patterns
|
||||
$lower = strtolower($value);
|
||||
foreach (self::WEAK_PATTERNS as $weak) {
|
||||
if (str_contains($lower, $weak)) {
|
||||
return [
|
||||
'class' => 'text-danger fw-bold',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($len < self::MIN_LENGTH) {
|
||||
return [
|
||||
'class' => 'text-danger',
|
||||
'label' => Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', self::MIN_LENGTH),
|
||||
];
|
||||
}
|
||||
|
||||
$hasUpper = preg_match('/[A-Z]/', $value);
|
||||
$hasLower = preg_match('/[a-z]/', $value);
|
||||
$hasDigit = preg_match('/[0-9]/', $value);
|
||||
|
||||
if ($hasUpper && $hasLower && $hasDigit && $len >= 32) {
|
||||
return [
|
||||
'class' => 'text-success',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($hasUpper && $hasLower && $hasDigit) {
|
||||
return [
|
||||
'class' => 'text-warning',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'class' => 'text-danger',
|
||||
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
|
||||
];
|
||||
}
|
||||
|
||||
private function getScript(): string
|
||||
{
|
||||
$minLen = self::MIN_LENGTH;
|
||||
$weakJson = json_encode(self::WEAK_PATTERNS);
|
||||
$labelNone = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
|
||||
$labelShort = json_encode(Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', $minLen));
|
||||
$labelWeak = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'));
|
||||
$labelOk = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'));
|
||||
$labelStrong = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'));
|
||||
|
||||
return <<<JS
|
||||
<script>
|
||||
function mokoWebcronGenerate(fieldId) {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
var result = '';
|
||||
var arr = new Uint8Array(32);
|
||||
(window.crypto || window.msCrypto).getRandomValues(arr);
|
||||
for (var i = 0; i < 32; i++) {
|
||||
result += chars.charAt(arr[i] % chars.length);
|
||||
}
|
||||
var field = document.getElementById(fieldId);
|
||||
field.value = result;
|
||||
mokoWebcronCheckStrength(field);
|
||||
}
|
||||
|
||||
function mokoWebcronCheckStrength(field) {
|
||||
var val = field.value;
|
||||
var el = document.getElementById(field.id + '-strength');
|
||||
var weak = {$weakJson};
|
||||
var lower = val.toLowerCase();
|
||||
|
||||
if (!val) {
|
||||
el.className = 'small mt-1 text-muted';
|
||||
el.textContent = '{$labelNone}';
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < weak.length; i++) {
|
||||
if (lower.indexOf(weak[i]) !== -1) {
|
||||
el.className = 'small mt-1 text-danger fw-bold';
|
||||
el.textContent = {$labelWeak};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (val.length < {$minLen}) {
|
||||
el.className = 'small mt-1 text-danger';
|
||||
el.textContent = {$labelShort};
|
||||
return;
|
||||
}
|
||||
|
||||
var hasUpper = /[A-Z]/.test(val);
|
||||
var hasLower = /[a-z]/.test(val);
|
||||
var hasDigit = /[0-9]/.test(val);
|
||||
|
||||
if (hasUpper && hasLower && hasDigit && val.length >= 32) {
|
||||
el.className = 'small mt-1 text-success';
|
||||
el.textContent = {$labelStrong};
|
||||
} else if (hasUpper && hasLower && hasDigit) {
|
||||
el.className = 'small mt-1 text-warning';
|
||||
el.textContent = {$labelOk};
|
||||
} else {
|
||||
el.className = 'small mt-1 text-danger';
|
||||
el.textContent = {$labelWeak};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class SnapshotModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get a single snapshot record.
|
||||
*
|
||||
* @param int $pk Primary key
|
||||
*
|
||||
* @return object|null
|
||||
*/
|
||||
public function getItem(int $pk = 0): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $pk);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObject() ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Model\ListModel;
|
||||
|
||||
class SnapshotsModel extends ListModel
|
||||
{
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields'])) {
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'description', 'a.description',
|
||||
'created', 'a.created',
|
||||
'data_size', 'a.data_size',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
protected function getListQuery()
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots', 'a'));
|
||||
|
||||
// Search filter
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
if (!empty($search)) {
|
||||
$search = $db->quote('%' . $db->escape($search, true) . '%');
|
||||
$query->where($db->quoteName('a.description') . ' LIKE ' . $search);
|
||||
}
|
||||
|
||||
// Ordering
|
||||
$orderCol = $this->state->get('list.ordering', 'a.created');
|
||||
$orderDirn = $this->state->get('list.direction', 'DESC');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDirn));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function populateState($ordering = 'a.created', $direction = 'DESC')
|
||||
{
|
||||
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
|
||||
$this->setState('filter.search', $search);
|
||||
|
||||
parent::populateState($ordering, $direction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\View\Snapshots;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $items;
|
||||
protected $pagination;
|
||||
protected $state;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE'), 'camera');
|
||||
|
||||
if ($user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::custom('snapshots.create', 'plus', '', 'COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE', false);
|
||||
}
|
||||
|
||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'snapshots.delete');
|
||||
}
|
||||
|
||||
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::preferences('com_mokosuitebackup');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -311,6 +311,34 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
// Expose for toolbar button
|
||||
window.mokosuitebackupStart = startSteppedBackup;
|
||||
|
||||
// Intercept Restore toolbar button to show the modal
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var restoreBtn = document.querySelector('[onclick*="backups.restore"], .button-upload');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get selected record from checkboxes
|
||||
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
|
||||
if (checked.length === 0) {
|
||||
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', true); ?>');
|
||||
return false;
|
||||
}
|
||||
document.getElementById('mb-restore-record-id').value = checked[0].value;
|
||||
document.getElementById('mb-restore-modal').style.display = 'block';
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Close restore modal
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('mb-restore-close') || e.target.id === 'mb-restore-modal') {
|
||||
document.getElementById('mb-restore-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// View Log modal handler
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.mb-view-log');
|
||||
@@ -353,6 +381,61 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Restore Confirmation Modal -->
|
||||
<div id="mb-restore-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:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-restore-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-record-id" value="">
|
||||
<div style="padding:1.5rem;">
|
||||
<div class="alert alert-danger">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_files" value="1" id="mb-restore-files" checked>
|
||||
<label class="form-check-label" for="mb-restore-files">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_FILES'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="restore_db" value="1" id="mb-restore-db" checked>
|
||||
<label class="form-check-label" for="mb-restore-db">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_DATABASE'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="preserve_config" value="1" id="mb-restore-config" checked>
|
||||
<label class="form-check-label" for="mb-restore-config">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG'); ?>
|
||||
<small class="text-muted d-block"><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="mb-restore-password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD'); ?></label>
|
||||
<input type="password" class="form-control" id="mb-restore-password" name="encryption_password"
|
||||
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-restore-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer Modal -->
|
||||
<div id="mb-log-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
|
||||
|
||||
@@ -118,8 +118,8 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($this->profiles)) : ?>
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<select id="mb-profile-select" class="form-select" style="max-width:250px;">
|
||||
<div class="mb-3">
|
||||
<select id="mb-profile-select" class="form-select mb-2">
|
||||
<?php foreach ($this->profiles as $profile) : ?>
|
||||
<option value="<?php echo (int) $profile->id; ?>">
|
||||
<?php echo $this->escape($profile->title); ?>
|
||||
@@ -127,7 +127,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="btn btn-primary" onclick="window.mokosuitebackupStart()">
|
||||
<button type="button" class="btn btn-primary w-100" onclick="window.mokosuitebackupStart()">
|
||||
<span class="icon-download" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW'); ?>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.multiselect');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$canManage = $user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup');
|
||||
|
||||
$listOrder = $this->escape($this->state->get('list.ordering'));
|
||||
$listDirn = $this->escape($this->state->get('list.direction'));
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" method="post" name="adminForm" id="adminForm">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div id="j-main-container" class="j-main-container">
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_NONE'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table" id="snapshotList">
|
||||
<caption class="visually-hidden"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TABLE_CAPTION'); ?></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="w-1 text-center">
|
||||
<?php echo HTMLHelper::_('grid.checkall'); ?>
|
||||
</td>
|
||||
<th scope="col">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', 'a.description', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-15">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CONTENT_TYPES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_SIZE', 'a.data_size', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.created', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $i => $item) : ?>
|
||||
<?php $types = json_decode($item->content_types, true) ?: []; ?>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->description); ?>
|
||||
<?php if ($item->status === 'fail') : ?>
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php foreach ($types as $type) : ?>
|
||||
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
<td class="text-center"><?php echo (int) $item->articles_count; ?></td>
|
||||
<td class="text-center"><?php echo (int) $item->categories_count; ?></td>
|
||||
<td class="text-center"><?php echo (int) $item->modules_count; ?></td>
|
||||
<td>
|
||||
<?php echo $item->data_size > 0 ? HTMLHelper::_('number.bytes', $item->data_size) : '—'; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('date', $item->created, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||
<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); ?>"
|
||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>">
|
||||
<span class="icon-upload"></span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo (int) $item->id; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php echo $this->pagination->getListFooter(); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="hidden" name="task" value="">
|
||||
<input type="hidden" name="boxchecked" value="0">
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Create Snapshot Modal -->
|
||||
<div id="mb-snapshot-create-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:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.create'); ?>" method="post" id="mb-snapshot-create-form">
|
||||
<div style="padding:1.5rem;">
|
||||
<div class="mb-3">
|
||||
<label for="mb-snap-desc" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION'); ?></label>
|
||||
<input type="text" class="form-control" id="mb-snap-desc" name="description" placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER'); ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="articles" id="mb-snap-articles" checked>
|
||||
<label class="form-check-label" for="mb-snap-articles">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="categories" id="mb-snap-categories" checked>
|
||||
<label class="form-check-label" for="mb-snap-categories">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="content_types[]" value="modules" id="mb-snap-modules" checked>
|
||||
<label class="form-check-label" for="mb-snap-modules">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC'); ?>)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-camera" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Snapshot Modal -->
|
||||
<div id="mb-snapshot-restore-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:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restore'); ?>" method="post" id="mb-snapshot-restore-form">
|
||||
<input type="hidden" name="id" id="mb-restore-id" value="">
|
||||
<div style="padding:1.5rem;">
|
||||
<p id="mb-restore-desc" class="fw-bold"></p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE'); ?></label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="replace" id="mb-mode-replace" checked>
|
||||
<label class="form-check-label" for="mb-mode-replace">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="radio" name="restore_mode" value="merge" id="mb-mode-merge">
|
||||
<label class="form-check-label" for="mb-mode-merge">
|
||||
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE'); ?></strong>
|
||||
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC'); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="mb-restore-types-container">
|
||||
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES'); ?></label>
|
||||
<!-- Populated by JS from data-types -->
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-0" id="mb-replace-warning">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Create Snapshot — intercept toolbar button
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var createBtn = document.querySelector('[onclick*="snapshots.create"], .button-plus');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.getElementById('mb-snapshot-create-modal').style.display = 'block';
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore Snapshot — click handler
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.mb-snapshot-restore');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
var id = btn.getAttribute('data-id');
|
||||
var types = JSON.parse(btn.getAttribute('data-types') || '[]');
|
||||
var desc = btn.getAttribute('data-desc');
|
||||
|
||||
document.getElementById('mb-restore-id').value = id;
|
||||
document.getElementById('mb-restore-desc').textContent = 'Restoring: ' + desc;
|
||||
|
||||
// Build type checkboxes using safe DOM methods
|
||||
var container = document.getElementById('mb-restore-types-container');
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
var heading = document.createElement('label');
|
||||
heading.className = 'form-label fw-bold';
|
||||
heading.textContent = <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES')); ?>;
|
||||
container.appendChild(heading);
|
||||
|
||||
var typeLabels = {
|
||||
articles: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES')); ?>,
|
||||
categories: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES')); ?>,
|
||||
modules: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES')); ?>
|
||||
};
|
||||
var allowedTypes = ['articles', 'categories', 'modules'];
|
||||
|
||||
types.forEach(function(type) {
|
||||
if (allowedTypes.indexOf(type) === -1) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'form-check';
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.className = 'form-check-input';
|
||||
input.type = 'checkbox';
|
||||
input.name = 'restore_types[]';
|
||||
input.value = type;
|
||||
input.id = 'mb-rtype-' + type;
|
||||
input.checked = true;
|
||||
|
||||
var label = document.createElement('label');
|
||||
label.className = 'form-check-label';
|
||||
label.setAttribute('for', 'mb-rtype-' + type);
|
||||
label.textContent = typeLabels[type] || type;
|
||||
|
||||
div.appendChild(input);
|
||||
div.appendChild(label);
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// Show/hide replace warning based on mode
|
||||
toggleReplaceWarning();
|
||||
|
||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'block';
|
||||
});
|
||||
|
||||
// Toggle warning when mode changes
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.name === 'restore_mode') {
|
||||
toggleReplaceWarning();
|
||||
}
|
||||
});
|
||||
|
||||
function toggleReplaceWarning() {
|
||||
var isReplace = document.getElementById('mb-mode-replace').checked;
|
||||
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Close modals
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('mb-modal-close') ||
|
||||
e.target.id === 'mb-snapshot-create-modal' ||
|
||||
e.target.id === 'mb-snapshot-restore-modal') {
|
||||
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
|
||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.25.00</version>
|
||||
<version>01.25.02</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.25.00</version>
|
||||
<version>01.25.02</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.25.00</version>
|
||||
<version>01.25.02</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.25.00</version>
|
||||
<version>01.25.02</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.25.00</version>
|
||||
<version>01.25.02</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.25.00</version>
|
||||
<version>01.25.02</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.25.00</version>
|
||||
<version>01.25.02</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.25.00</version>
|
||||
<version>01.25.02</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -150,6 +150,9 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
// Create default backup directory in site root
|
||||
$this->createBackupDirectory();
|
||||
|
||||
// Generate a random webcron secret word
|
||||
$this->generateWebcronSecret();
|
||||
|
||||
// Create default scheduled task for backup automation
|
||||
$this->createDefaultScheduledTask();
|
||||
}
|
||||
@@ -185,6 +188,58 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random webcron secret word on fresh install.
|
||||
*/
|
||||
private function generateWebcronSecret(): void
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load current component params
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
->setLimit(1);
|
||||
$db->setQuery($query);
|
||||
$rawParams = $db->loadResult();
|
||||
|
||||
$params = json_decode($rawParams ?: '{}', true) ?: [];
|
||||
|
||||
// Only generate if not already set
|
||||
if (!empty($params['webcron_secret'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
$secret = '';
|
||||
$bytes = random_bytes(32);
|
||||
|
||||
for ($i = 0; $i < 32; $i++) {
|
||||
$secret .= $chars[ord($bytes[$i]) % strlen($chars)];
|
||||
}
|
||||
|
||||
$params['webcron_secret'] = $secret;
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: generateWebcronSecret() failed: ' . $e->getMessage());
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'MokoSuiteBackup could not generate a random webcron secret. '
|
||||
. 'Please set one manually in the component options to secure the webcron endpoint.',
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function enableBundledPlugins(): void
|
||||
{
|
||||
$folders = ['system', 'quickicon', 'task', 'webservices', 'console', 'content', 'actionlog'];
|
||||
@@ -388,6 +443,12 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
'img' => 'class:database',
|
||||
'menu_icon' => 'icon-database',
|
||||
],
|
||||
[
|
||||
'link' => 'index.php?option=com_mokosuitebackup&view=snapshots',
|
||||
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS',
|
||||
'img' => 'class:camera',
|
||||
'menu_icon' => 'icon-camera',
|
||||
],
|
||||
[
|
||||
'link' => 'index.php?option=com_mokosuitebackup&view=profiles',
|
||||
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_PROFILES',
|
||||
@@ -522,6 +583,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
||||
$iconMap = [
|
||||
'view=dashboard' => 'class:home',
|
||||
'view=backups' => 'class:database',
|
||||
'view=snapshots' => 'class:camera',
|
||||
'view=profiles' => 'class:cog',
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user