feat: Content snapshots, restore UI, and config hardening (v01.25.00) #69

Merged
jmiller merged 9 commits from feature/47-backup-status-helper into main 2026-06-21 22:34:53 +00:00
35 changed files with 3187 additions and 1334 deletions
+1
View File
@@ -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
+66 -66
View File
@@ -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"
+2 -2
View File
@@ -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"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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&amp;view=backups"
img="class:database"
alt="Backups">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
<menu link="option=com_mokosuitebackup&amp;view=snapshots"
img="class:camera"
alt="Snapshots">COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS</menu>
<menu link="option=com_mokosuitebackup&amp;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>
+1 -1
View File
@@ -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>
+62
View File
@@ -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',
];