* @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; use Joomla\Event\Event; 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 '#__tags' => 'id', '#__fields' => 'id', '#__fields_values' => null, // composite key, handled specially '#__fields_categories' => 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'); } $mode = $this->normaliseMode($mode); if ($mode === 'duplicate') { // Insert-as-new-with-remapped-IDs is a separate, heavier engine // (article + category nested-set + tag/field/workflow reference // remapping) that needs its own tested implementation. return [ 'success' => false, 'message' => 'Duplicate mode (insert as new with remapped IDs) is not available yet — use overwrite or create.', ]; } if (!in_array($mode, ['replace', 'merge', 'create'], true)) { 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); } elseif ($mode === 'create') { $rowCount = $this->restoreCreate($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'); // Send snapshot restore notification try { $profile = NotificationSender::getDefaultProfile(); if ($profile) { $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; NotificationSender::sendRestoreNotification($profile, 'snapshot_restore', [ 'mode' => $mode, 'content_types' => $restoreTypes, 'row_count' => $totalRows, 'user' => $userName . ' (ID: ' . $userIdVal . ')', ], implode("\n", $this->log)); } } catch (\Throwable $e) { error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage()); } // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode); 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()); // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode); 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; } /** * Create mode: insert only rows that don't already exist; never overwrite. * Non-destructive — existing content on the target is left untouched. Used * by master→slave injection when the master must not clobber local edits. */ private function restoreCreate(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])) { $exists = $db->setQuery( $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName($realTable)) ->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk])) )->loadResult(); if ($exists) { continue; } $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) { continue; } throw $e; } } $count++; } return $count; } /** * Normalise external mode names to the engine's internal modes. * overwrite→replace, skip/new→create, copy→duplicate; passthrough otherwise. */ private function normaliseMode(string $mode): string { $map = [ 'overwrite' => 'replace', 'replace' => 'replace', 'update' => 'merge', 'merge' => 'merge', 'skip' => 'create', 'new' => 'create', 'create' => 'create', 'copy' => 'duplicate', 'duplicate' => 'duplicate', ]; return $map[strtolower(trim($mode))] ?? strtolower(trim($mode)); } /** * 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; case '#__tags': // Only delete tags that exist in the snapshot — never wipe all tags $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 '#__fields': // Only delete custom fields scoped to com_content.article $query->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); break; case '#__fields_values': // Only delete field values for com_content.article fields $prefix = $db->getPrefix(); $fTable = $prefix . 'fields'; $subQuery = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName($fTable)) ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); break; case '#__fields_categories': // Delete field-category mappings for com_content.article fields only $prefix = $db->getPrefix(); $fTable = $prefix . 'fields'; $subQuery = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName($fTable)) ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); 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'; $tables[] = '#__tags'; $tables[] = '#__fields'; $tables[] = '#__fields_values'; $tables[] = '#__fields_categories'; } if (in_array('categories', $types)) { $tables[] = '#__categories'; } if (in_array('modules', $types)) { $tables[] = '#__modules'; $tables[] = '#__modules_menu'; } return array_unique($tables); } /** * Restore only selected articles (and their related rows) from a snapshot. * * Uses merge/upsert mode: updates existing rows by ID, inserts missing ones. * * @param int $snapshotId Snapshot record ID * @param array $articleIds Article IDs to restore * * @return array{success: bool, message: string, restored?: int, log?: string} */ public function restoreSelectedArticles(int $snapshotId, array $articleIds): array { if (empty($articleIds)) { return ['success' => false, 'message' => 'No article IDs provided']; } $articleIds = array_map('intval', $articleIds); $articleIds = array_filter($articleIds, fn($id) => $id > 0); if (empty($articleIds)) { return ['success' => false, 'message' => 'No valid article IDs provided']; } $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']; } $contentTable = $data['tables']['#__content'] ?? []; if (empty($contentTable)) { return ['success' => false, 'message' => 'Snapshot does not contain articles']; } // Filter #__content rows to only selected article IDs $selectedRows = array_filter($contentTable, fn($row) => in_array((int) ($row['id'] ?? 0), $articleIds, true)); if (empty($selectedRows)) { return ['success' => false, 'message' => 'None of the selected article IDs exist in this snapshot']; } $foundIds = array_map(fn($row) => (int) $row['id'], $selectedRows); $this->log('Restoring ' . count($selectedRows) . ' articles: IDs ' . implode(', ', $foundIds)); // Filter workflow_associations for selected articles $workflowRows = []; if (!empty($data['tables']['#__workflow_associations'])) { $workflowRows = array_filter( $data['tables']['#__workflow_associations'], fn($row) => in_array((int) ($row['item_id'] ?? 0), $foundIds, true) ); } // Filter tag_map entries for selected articles $tagMapRows = []; if (!empty($data['tables']['#__contentitem_tag_map'])) { $tagMapRows = array_filter( $data['tables']['#__contentitem_tag_map'], fn($row) => in_array((int) ($row['content_item_id'] ?? 0), $foundIds, true) && str_starts_with($row['type_alias'] ?? '', 'com_content.') ); } $prefix = $db->getPrefix(); $totalRows = 0; try { $db->transactionStart(); // Restore articles using merge/upsert $realTable = str_replace('#__', $prefix, '#__content'); $rowCount = $this->restoreMerge($db, $realTable, '#__content', array_values($selectedRows)); $totalRows += $rowCount; $this->log(' #__content: ' . $rowCount . ' rows restored'); // Restore workflow associations if (!empty($workflowRows)) { $realTable = str_replace('#__', $prefix, '#__workflow_associations'); $rowCount = $this->restoreMerge($db, $realTable, '#__workflow_associations', array_values($workflowRows)); $totalRows += $rowCount; $this->log(' #__workflow_associations: ' . $rowCount . ' rows restored'); } // Restore tag map entries if (!empty($tagMapRows)) { $realTable = str_replace('#__', $prefix, '#__contentitem_tag_map'); $rowCount = $this->restoreMerge($db, $realTable, '#__contentitem_tag_map', array_values($tagMapRows)); $totalRows += $rowCount; $this->log(' #__contentitem_tag_map: ' . $rowCount . ' rows restored'); } $db->transactionCommit(); $this->log('Selective restore complete: ' . $totalRows . ' total rows'); // Send notification try { $profile = NotificationSender::getDefaultProfile(); if ($profile) { $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; NotificationSender::sendRestoreNotification($profile, 'snapshot_selective_restore', [ 'mode' => 'selective', 'article_ids' => $foundIds, 'row_count' => $totalRows, 'user' => $userName . ' (ID: ' . $userIdVal . ')', ], implode("\n", $this->log)); } } catch (\Throwable $e) { error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage()); } // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective'); return [ 'success' => true, 'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows), 'restored' => count($selectedRows), '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()); // Dispatch event for actionlog and other listeners $this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective'); return [ 'success' => false, 'message' => 'Selective restore failed: ' . $e->getMessage(), 'log' => implode("\n", $this->log), ]; } } /** * Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react. */ private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void { try { $app = Factory::getApplication(); $event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [ 'success' => $success, 'snapshot_id' => $snapshotId, 'mode' => $mode, ]); $app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event); } catch (\Throwable $e) { // Never let a listener failure break the restore result, but log it error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage()); } } private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; } }