fix: scope module DELETE to snapshot IDs in replace mode

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.
This commit is contained in:
Jonathan Miller
2026-06-21 15:50:41 -05:00
parent ef31713029
commit 854383a899
2 changed files with 38 additions and 6 deletions
@@ -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));
@@ -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;
}