ad1c0cf349
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 28s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 17s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m55s
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
The fields_values table is shared across all Joomla extensions. Previously, dump captured ALL field values and restore deleted ALL field values, destroying data for contacts, users, and other extensions. Now scoped via subquery on field_id WHERE context = 'com_content.article'.
375 lines
11 KiB
PHP
375 lines
11 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
|
|
'#__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');
|
|
|
|
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;
|
|
}
|
|
}
|