* @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 '#__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'); } 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'); // 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()); } 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; 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); } private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; } }