From 854383a89921e27a3672cd1426af1bb194fced1c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 15:50:41 -0500 Subject: [PATCH] fix: scope module DELETE to snapshot IDs in replace mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The truncateFiltered() method ran unfiltered DELETE FROM #__modules in replace mode, which would wipe ALL site modules (admin toolbar, login, menus) — not just the ones in the snapshot. Now scoped to only delete modules whose IDs exist in the snapshot data. Also scopes #__modules_menu delete to snapshot module IDs, and adds defense-in-depth validation of restore_mode in the controller. --- .../src/Controller/SnapshotsController.php | 5 +++ .../src/Engine/SnapshotRestoreEngine.php | 39 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php index 49c6f64a..74b5d4de 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php @@ -82,6 +82,11 @@ class SnapshotsController extends AdminController $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)); diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index d8dbb28c..7e5a05c4 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -162,12 +162,12 @@ class SnapshotRestoreEngine } /** - * Replace mode: truncate table, then insert all rows. + * 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); + $this->truncateFiltered($db, $realTable, $abstractTable, $rows); $count = 0; @@ -221,13 +221,16 @@ class SnapshotRestoreEngine } /** - * Delete rows from a table, filtering to relevant content where needed. + * 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): void + private function truncateFiltered(object $db, string $realTable, string $abstractTable, array $rows): 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')); @@ -241,7 +244,31 @@ class SnapshotRestoreEngine $query->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%')); break; - // These tables are fully owned by the content type + 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; }