Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php
T
Jonathan Miller d5421738b7
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Generic: Project CI / Lint & Validate (pull_request) Successful in 44s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 49s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 51s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
fix: address PR review findings — error handling and safety
Fixes from code review and silent failure audit:

- SnapshotRestoreEngine: catch only duplicate key errors (MySQL 1062)
  in merge mode, re-throw all other exceptions instead of swallowing
- SnapshotRestoreEngine: add json_last_error() check for better error
  messages on corrupt snapshot files
- SnapshotRestoreEngine: log warnings when set_time_limit/ini_set fail
- SnapshotEngine: use strlen($json) instead of filesize() to avoid
  race conditions; catch \Exception instead of \Throwable
- SnapshotsController: remove @unlink suppression, add try-catch
  around delete loop with partial failure reporting
- script.php: add user-facing warning when webcron secret generation
  fails (was silently swallowed, inconsistent with other catch blocks)
2026-06-21 17:21:55 -05:00

325 lines
9.0 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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
{
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');
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;
// #__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';
}
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;
}
}