f9c1c6b186
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Require Docs Update (pull_request) Has been cancelled
Universal: PR Check / Wiki Update Reminder (pull_request) Has been cancelled
Universal: PR Check / Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Has been cancelled
Generic: Project CI / Tests (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
fix(review): open-redirect, btoa UTF-8 safety, honest duplicate-mode txn Pre-release review findings on dev -> main: - Return-URL open-redirect (MED): a base64 returnurl like "/\evil.com" passed the root-relative allow-list (raw[1] is "\", not "/"), and a browser normalises leading "/\" to "//", yielding a protocol-relative redirect off-site. Reject any backslash or control char in the decoded return URL outright — legitimate Joomla admin URLs never contain them. - installer-backup.js btoa() (LOW): btoa() throws on non-Latin1 input (e.g. a UTF-8 filter value in the list query string), and the throw was uncaught after preventDefault(), leaving the toolbar button dead. Build the redirect (UTF-8-safe base64) BEFORE swallowing the click; if it still fails, bail without preventing so Joomla proceeds normally. - Snapshot duplicate-mode transaction (HIGH): restore() wrapped duplicate mode in transactionStart()/Rollback(), but duplicate mode writes via Joomla Table API whose Nested tables (categories/tags) issue LOCK TABLES, which implicitly COMMITS the open transaction in MySQL — so the rollback promise was illusory and a mid-inject failure left a slave half-updated. Run only the raw-DB modes (replace/create/merge) inside the transaction; duplicate mode runs outside it, matching its existing per-item defensive design (bad item skipped + logged, never fatal). Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i @
963 lines
29 KiB
PHP
963 lines
29 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;
|
|
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 (!in_array($mode, ['replace', 'merge', 'create', 'duplicate'], 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;
|
|
|
|
// Build list of tables to restore based on selected types
|
|
$tablesToRestore = $this->getTablesToRestore($restoreTypes);
|
|
|
|
/* Duplicate mode inserts brand-new records through Joomla's Table API,
|
|
whose Nested tables (categories, tags) issue LOCK TABLES — which
|
|
implicitly COMMITS any open transaction in MySQL/InnoDB. Wrapping it
|
|
in transactionStart()/Rollback() would be a false atomicity promise:
|
|
the first category/tag store would commit and defeat the rollback.
|
|
Duplicate mode is therefore best-effort and per-item defensive (a bad
|
|
item is skipped and logged, never fatal) rather than all-or-nothing.
|
|
The raw-DB modes (replace/create/merge) remain fully transactional. */
|
|
$useTransaction = ($mode !== 'duplicate');
|
|
|
|
try {
|
|
if ($useTransaction) {
|
|
$db->transactionStart();
|
|
}
|
|
|
|
if ($mode === 'duplicate') {
|
|
$totalRows = $this->restoreDuplicate($data, $restoreTypes);
|
|
} else {
|
|
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');
|
|
}
|
|
}
|
|
|
|
if ($useTransaction) {
|
|
$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) {
|
|
if ($useTransaction) {
|
|
try {
|
|
$db->transactionRollback();
|
|
$this->log('Transaction rolled back');
|
|
} catch (\Exception $rollbackEx) {
|
|
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
|
|
}
|
|
} else {
|
|
$this->log('Duplicate mode is not transactional; records already inserted are left in place.');
|
|
}
|
|
|
|
$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));
|
|
}
|
|
|
|
/**
|
|
* Duplicate mode: insert snapshot content as BRAND-NEW records (new IDs),
|
|
* leaving existing content untouched. Uses Joomla's Table API so assets,
|
|
* UCM entries, category/tag nested-sets and alias uniqueness are handled by
|
|
* core. Every item is wrapped in try/catch — a bad item is skipped + logged,
|
|
* never fatal — so a partial import degrades gracefully rather than corrupting.
|
|
*
|
|
* Known limits: workflow stage is left to the article table's default;
|
|
* references to content NOT included in the snapshot keep their original IDs.
|
|
*
|
|
* @return int Number of new records created
|
|
*/
|
|
private function restoreDuplicate(array $data, array $types): int
|
|
{
|
|
$app = Factory::getApplication();
|
|
$db = Factory::getDbo();
|
|
$tables = $data['tables'] ?? [];
|
|
$count = 0;
|
|
|
|
$catMap = [];
|
|
$articleMap = [];
|
|
$moduleMap = [];
|
|
|
|
// --- Categories (parents before children so the remap resolves) ---
|
|
if (in_array('categories', $types, true) && !empty($tables['#__categories'])) {
|
|
$factory = $app->bootComponent('com_categories')->getMVCFactory();
|
|
$cats = $tables['#__categories'];
|
|
usort($cats, static fn ($a, $b) => ((int) ($a['level'] ?? 0)) <=> ((int) ($b['level'] ?? 0)));
|
|
|
|
foreach ($cats as $row) {
|
|
$oldId = (int) ($row['id'] ?? 0);
|
|
$oldParent = (int) ($row['parent_id'] ?? 1);
|
|
$newParent = $catMap[$oldParent] ?? ($oldParent > 1 ? $oldParent : 1);
|
|
|
|
unset($row['id'], $row['asset_id'], $row['lft'], $row['rgt'], $row['level'], $row['path']);
|
|
$row['extension'] = 'com_content';
|
|
$row['parent_id'] = $newParent;
|
|
$row['alias'] = $this->uniqueAlias($db, '#__categories', (string) ($row['alias'] ?? ''), ['extension' => 'com_content']);
|
|
|
|
try {
|
|
$table = $factory->createTable('Category', 'Administrator');
|
|
$table->setLocation($newParent, 'last-child');
|
|
|
|
if ($table->bind($row) && $table->check() && $table->store()) {
|
|
$catMap[$oldId] = (int) $table->id;
|
|
$count++;
|
|
} else {
|
|
$this->log(' Category skipped: ' . $table->getError());
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$this->log(' Category "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Articles ---
|
|
if (in_array('articles', $types, true) && !empty($tables['#__content'])) {
|
|
$factory = $app->bootComponent('com_content')->getMVCFactory();
|
|
$featured = [];
|
|
|
|
foreach ($tables['#__content_frontpage'] ?? [] as $fp) {
|
|
$featured[(int) ($fp['content_id'] ?? 0)] = true;
|
|
}
|
|
|
|
$tagTitles = $this->buildArticleTagTitles($tables);
|
|
|
|
foreach ($tables['#__content'] as $row) {
|
|
$oldId = (int) ($row['id'] ?? 0);
|
|
$oldCat = (int) ($row['catid'] ?? 0);
|
|
|
|
unset($row['id'], $row['asset_id']);
|
|
|
|
if (isset($catMap[$oldCat])) {
|
|
$row['catid'] = $catMap[$oldCat];
|
|
}
|
|
|
|
$row['alias'] = $this->uniqueAlias($db, '#__content', (string) ($row['alias'] ?? ''), ['catid' => (int) ($row['catid'] ?? 0)]);
|
|
|
|
try {
|
|
$table = $factory->createTable('Article', 'Administrator');
|
|
$tagIds = $this->resolveTagIds($db, $app, $tagTitles[$oldId] ?? []);
|
|
|
|
if ($tagIds) {
|
|
$table->newTags = $tagIds;
|
|
}
|
|
|
|
if ($table->bind($row) && $table->check() && $table->store()) {
|
|
$newId = (int) $table->id;
|
|
$articleMap[$oldId] = $newId;
|
|
$count++;
|
|
|
|
if (!empty($featured[$oldId])) {
|
|
try {
|
|
$db->insertObject($db->getPrefix() . 'content_frontpage', (object) ['content_id' => $newId, 'ordering' => 0]);
|
|
} catch (\Throwable $e) {
|
|
// featured-ordering row is non-critical
|
|
}
|
|
}
|
|
} else {
|
|
$this->log(' Article skipped: ' . $table->getError());
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$this->log(' Article "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Custom-field values for the duplicated articles.
|
|
foreach ($tables['#__fields_values'] ?? [] as $fv) {
|
|
$oldItem = (int) ($fv['item_id'] ?? 0);
|
|
|
|
if (!isset($articleMap[$oldItem])) {
|
|
continue;
|
|
}
|
|
|
|
$fv['item_id'] = (string) $articleMap[$oldItem];
|
|
|
|
try {
|
|
$db->insertObject($db->getPrefix() . 'fields_values', (object) $fv);
|
|
} catch (\Throwable $e) {
|
|
// duplicate/invalid field value — skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Modules ---
|
|
if (in_array('modules', $types, true) && !empty($tables['#__modules'])) {
|
|
$factory = $app->bootComponent('com_modules')->getMVCFactory();
|
|
|
|
foreach ($tables['#__modules'] as $row) {
|
|
$oldId = (int) ($row['id'] ?? 0);
|
|
unset($row['id'], $row['asset_id']);
|
|
|
|
try {
|
|
$table = $factory->createTable('Module', 'Administrator');
|
|
|
|
if ($table->bind($row) && $table->check() && $table->store()) {
|
|
$moduleMap[$oldId] = (int) $table->id;
|
|
$count++;
|
|
} else {
|
|
$this->log(' Module skipped: ' . $table->getError());
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$this->log(' Module "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
foreach ($tables['#__modules_menu'] ?? [] as $mm) {
|
|
$oldMod = (int) ($mm['moduleid'] ?? 0);
|
|
|
|
if (!isset($moduleMap[$oldMod])) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$db->insertObject($db->getPrefix() . 'modules_menu', (object) ['moduleid' => $moduleMap[$oldMod], 'menuid' => (int) ($mm['menuid'] ?? 0)]);
|
|
} catch (\Throwable $e) {
|
|
// duplicate assignment — skip
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->log('Duplicated ' . $count . ' new records (categories: ' . count($catMap) . ', articles: ' . count($articleMap) . ', modules: ' . count($moduleMap) . ')');
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Make an alias unique within a scope by appending -2, -3, … if needed.
|
|
*/
|
|
private function uniqueAlias(object $db, string $abstractTable, string $alias, array $scope): string
|
|
{
|
|
$alias = $alias !== '' ? $alias : 'item';
|
|
$table = str_replace('#__', $db->getPrefix(), $abstractTable);
|
|
$base = $alias;
|
|
$n = 1;
|
|
|
|
while (true) {
|
|
$query = $db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName($table))
|
|
->where($db->quoteName('alias') . ' = ' . $db->quote($alias));
|
|
|
|
foreach ($scope as $col => $val) {
|
|
$query->where($db->quoteName($col) . ' = ' . $db->quote($val));
|
|
}
|
|
|
|
if ((int) $db->setQuery($query)->loadResult() === 0) {
|
|
return $alias;
|
|
}
|
|
|
|
$n++;
|
|
$alias = $base . '-' . $n;
|
|
|
|
if ($n > 100) {
|
|
return $base . '-' . substr(md5((string) mt_rand()), 0, 6);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map old article id → its tag titles, from the snapshot's tag map + tags.
|
|
*
|
|
* @return array<int,string[]>
|
|
*/
|
|
private function buildArticleTagTitles(array $tables): array
|
|
{
|
|
$tagTitle = [];
|
|
|
|
foreach ($tables['#__tags'] ?? [] as $tag) {
|
|
$tagTitle[(int) ($tag['id'] ?? 0)] = (string) ($tag['title'] ?? '');
|
|
}
|
|
|
|
$byArticle = [];
|
|
|
|
foreach ($tables['#__contentitem_tag_map'] ?? [] as $map) {
|
|
if (strpos((string) ($map['type_alias'] ?? ''), 'com_content.article') !== 0) {
|
|
continue;
|
|
}
|
|
|
|
$articleId = (int) ($map['content_item_id'] ?? 0);
|
|
$title = $tagTitle[(int) ($map['tag_id'] ?? 0)] ?? '';
|
|
|
|
if ($articleId && $title !== '') {
|
|
$byArticle[$articleId][] = $title;
|
|
}
|
|
}
|
|
|
|
return $byArticle;
|
|
}
|
|
|
|
/**
|
|
* Resolve tag titles to target tag IDs, creating any that don't exist.
|
|
*
|
|
* @return int[]
|
|
*/
|
|
private function resolveTagIds(object $db, object $app, array $titles): array
|
|
{
|
|
$ids = [];
|
|
|
|
foreach (array_unique($titles) as $title) {
|
|
try {
|
|
$existing = $db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('id'))
|
|
->from($db->quoteName('#__tags'))
|
|
->where($db->quoteName('title') . ' = ' . $db->quote($title))
|
|
)->loadResult();
|
|
|
|
if ($existing) {
|
|
$ids[] = (int) $existing;
|
|
continue;
|
|
}
|
|
|
|
$table = $app->bootComponent('com_tags')->getMVCFactory()->createTable('Tag', 'Administrator');
|
|
$table->setLocation(1, 'last-child');
|
|
|
|
if ($table->bind(['title' => $title, 'alias' => '', 'published' => 1, 'parent_id' => 1])
|
|
&& $table->check() && $table->store()) {
|
|
$ids[] = (int) $table->id;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// tag resolution is best-effort
|
|
}
|
|
}
|
|
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|