From ef3171302973fe41b2e3d4c988cc4ac5701786c7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 15:25:53 -0500 Subject: [PATCH] feat: content snapshots, restore UI, and config hardening (v01.25.00) Add content snapshot system for lightweight article/category/module versioning independent of full backups. Snapshots store as JSON files with replace or merge restore modes, wrapped in DB transactions. - SnapshotEngine: dumps articles, categories, modules + related tables (workflow_associations, tag maps, frontpage) to JSON - SnapshotRestoreEngine: replace (clean slate) or merge (upsert) mode - Full MVC: controller, models, view, template with create/restore modals - New ACL permission: mokosuitebackup.snapshot.manage - Submenu entry with camera icon, upgrade SQL for snapshots table Improve full-site restore UI with confirmation modal offering options for files, database, preserve config, and encryption password. Config improvements: - WebcronSecretField: CSPRNG generator, strength meter, rejects weak patterns (password, admin, secret), enforces min 16 chars - IpWhitelistField: table-based management, current IP detection with one-click "Add my IP" button - Default profile shows "Title (#ID)" format - Default backup dir uses [DEFAULT_DIR] placeholder - Install script generates random 32-char webcron secret - Dashboard quick actions: full-width dropdown with button below --- .../packages/com_mokosuitebackup/access.xml | 1 + .../packages/com_mokosuitebackup/config.xml | 13 +- .../language/en-GB/com_mokosuitebackup.ini | 57 +++ .../com_mokosuitebackup/mokosuitebackup.xml | 5 +- .../com_mokosuitebackup/sql/install.mysql.sql | 17 + .../sql/updates/mysql/01.25.00.sql | 16 + .../src/Controller/SnapshotsController.php | 158 +++++++++ .../src/Engine/SnapshotEngine.php | 238 +++++++++++++ .../src/Engine/SnapshotRestoreEngine.php | 283 +++++++++++++++ .../src/Field/IpWhitelistField.php | 203 +++++++++++ .../src/Field/WebcronSecretField.php | 184 ++++++++++ .../src/Model/SnapshotModel.php | 37 ++ .../src/Model/SnapshotsModel.php | 64 ++++ .../src/View/Snapshots/HtmlView.php | 55 +++ .../src/View/Snapshots/index.html | 1 + .../tmpl/backups/default.php | 83 +++++ .../tmpl/dashboard/default.php | 6 +- .../tmpl/snapshots/default.php | 325 ++++++++++++++++++ .../tmpl/snapshots/index.html | 1 + source/pkg_mokosuitebackup.xml | 2 +- source/script.php | 57 +++ 21 files changed, 1795 insertions(+), 11 deletions(-) create mode 100644 source/packages/com_mokosuitebackup/sql/updates/mysql/01.25.00.sql create mode 100644 source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php create mode 100644 source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php create mode 100644 source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php create mode 100644 source/packages/com_mokosuitebackup/src/Field/IpWhitelistField.php create mode 100644 source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php create mode 100644 source/packages/com_mokosuitebackup/src/Model/SnapshotModel.php create mode 100644 source/packages/com_mokosuitebackup/src/Model/SnapshotsModel.php create mode 100644 source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php create mode 100644 source/packages/com_mokosuitebackup/src/View/Snapshots/index.html create mode 100644 source/packages/com_mokosuitebackup/tmpl/snapshots/default.php create mode 100644 source/packages/com_mokosuitebackup/tmpl/snapshots/index.html diff --git a/source/packages/com_mokosuitebackup/access.xml b/source/packages/com_mokosuitebackup/access.xml index c7e6f78..53fcc84 100644 --- a/source/packages/com_mokosuitebackup/access.xml +++ b/source/packages/com_mokosuitebackup/access.xml @@ -11,5 +11,6 @@ + diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml index 5b93f7f..60428d2 100644 --- a/source/packages/com_mokosuitebackup/config.xml +++ b/source/packages/com_mokosuitebackup/config.xml @@ -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" /> - + diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index 4aefcf4..3908dfc 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -299,6 +299,63 @@ 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." + +; 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." diff --git a/source/packages/com_mokosuitebackup/mokosuitebackup.xml b/source/packages/com_mokosuitebackup/mokosuitebackup.xml index 6271fcc..0024ef1 100644 --- a/source/packages/com_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/com_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> MokoSuiteBackup - 01.24.00 + 01.25.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -45,6 +45,9 @@ COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS + COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS COM_MOKOJOOMBACKUP_SUBMENU_PROFILES diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index 70936dc..03e32bf 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -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`, diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.25.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.25.00.sql new file mode 100644 index 0000000..20e4f51 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.25.00.sql @@ -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; diff --git a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php new file mode 100644 index 0000000..49c6f64 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php @@ -0,0 +1,158 @@ + + * @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'); + + 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; + + foreach ($cid as $id) { + $id = (int) $id; + + // 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)) { + @unlink($dataFile); + } + + // Delete record + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $db->execute(); + $deleted++; + } + + $this->setMessage(Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted)); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php new file mode 100644 index 0000000..6913c62 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -0,0 +1,238 @@ + + * @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 = filesize($filePath); + $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 (\Throwable $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; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php new file mode 100644 index 0000000..d8dbb28 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -0,0 +1,283 @@ + + * @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 + { + @set_time_limit(0); + @ini_set('memory_limit', '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 (!is_array($data) || empty($data['tables'])) { + return ['success' => false, 'message' => 'Invalid snapshot data format']; + } + + $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: truncate table, then insert all 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); + + $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 — just insert (for merge, we skip duplicates) + try { + $db->insertObject($realTable, $obj); + } catch (\Exception $e) { + // Duplicate key — skip silently in merge mode + } + } + + $count++; + } + + return $count; + } + + /** + * Delete rows from a table, filtering to relevant content where needed. + */ + private function truncateFiltered(object $db, string $realTable, string $abstractTable): void + { + $query = $db->getQuery(true)->delete($db->quoteName($realTable)); + + // Only delete com_content rows from shared tables + 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; + + // These tables are fully owned by the content type + 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; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Field/IpWhitelistField.php b/source/packages/com_mokosuitebackup/src/Field/IpWhitelistField.php new file mode 100644 index 0000000..0d4c1df --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Field/IpWhitelistField.php @@ -0,0 +1,203 @@ + + * @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 = ''; + + // Current IP display + $html .= '
' + . ' ' + . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP') . ': ' + . '' . htmlspecialchars($currentIp) . ''; + + $alreadyAdded = in_array($currentIp, $ips); + if (!$alreadyAdded) { + $html .= ' '; + } else { + $html .= ' ' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED') . ''; + } + + $html .= '
'; + + // IP table + $html .= ''; + $html .= '' + . '' + . '' + . ''; + $html .= ''; + + if (empty($ips)) { + $html .= ''; + } else { + foreach ($ips as $ip) { + $html .= '' + . '' + . '' + . ''; + } + } + + $html .= '
' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS') . '' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE') . '
' + . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE') + . '
' . htmlspecialchars($ip) . '' + . '
'; + + // Add custom IP + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + + $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 << +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 = ''; +} + +JS; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php b/source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php new file mode 100644 index 0000000..1be7aff --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php @@ -0,0 +1,184 @@ + + * @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 = '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + $html .= '
' + . $strengthText . '
'; + + $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 << +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}; + } +} + +JS; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Model/SnapshotModel.php b/source/packages/com_mokosuitebackup/src/Model/SnapshotModel.php new file mode 100644 index 0000000..366bd5e --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Model/SnapshotModel.php @@ -0,0 +1,37 @@ + + * @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; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Model/SnapshotsModel.php b/source/packages/com_mokosuitebackup/src/Model/SnapshotsModel.php new file mode 100644 index 0000000..26b37e9 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Model/SnapshotsModel.php @@ -0,0 +1,64 @@ + + * @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); + } +} diff --git a/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php new file mode 100644 index 0000000..089e7e4 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php @@ -0,0 +1,55 @@ + + * @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'); + } + } +} diff --git a/source/packages/com_mokosuitebackup/src/View/Snapshots/index.html b/source/packages/com_mokosuitebackup/src/View/Snapshots/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/View/Snapshots/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index eebb683..c7a4bf6 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -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(''); + 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')); })(); + + +