8a4ebe1bde
Browse articles inside a snapshot and restore individual items: - SnapshotRestoreEngine::restoreSelectedArticles() merges by ID - AjaxController::browseSnapshot() returns article list as JSON - SnapshotsController::restoreSelected() handles selective restore - Browse modal with checkboxes + Restore Selected button Closes #58
326 lines
9.1 KiB
PHP
326 lines
9.1 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
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Language\Text;
|
|
use Joomla\CMS\MVC\Controller\AdminController;
|
|
use Joomla\CMS\Router\Route;
|
|
use Joomla\CMS\Session\Session;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
|
|
|
class SnapshotsController extends AdminController
|
|
{
|
|
protected $text_prefix = 'COM_MOKOJOOMBACKUP_SNAPSHOTS';
|
|
|
|
public function getModel($name = 'Snapshot', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
|
{
|
|
return parent::getModel($name, $prefix, $config);
|
|
}
|
|
|
|
/**
|
|
* Create a new content snapshot.
|
|
*/
|
|
public function create(): void
|
|
{
|
|
$this->checkToken();
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$contentTypes = $this->input->get('content_types', [], 'array');
|
|
$description = $this->input->getString('description', '');
|
|
|
|
if (empty($contentTypes)) {
|
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SnapshotEngine();
|
|
$result = $engine->create($contentTypes, $description);
|
|
|
|
if ($result['success']) {
|
|
$this->setMessage($result['message']);
|
|
} else {
|
|
$this->setMessage($result['message'], 'error');
|
|
}
|
|
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
}
|
|
|
|
/**
|
|
* Restore from a content snapshot.
|
|
*/
|
|
public function restore(): void
|
|
{
|
|
$this->checkToken();
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('id', 0);
|
|
$mode = $this->input->getCmd('restore_mode', 'replace');
|
|
$contentTypes = $this->input->get('restore_types', [], 'array');
|
|
|
|
// Enforce valid restore mode at controller boundary
|
|
if (!in_array($mode, ['replace', 'merge'], true)) {
|
|
$mode = 'replace';
|
|
}
|
|
|
|
if (!$id) {
|
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SnapshotRestoreEngine();
|
|
$result = $engine->restore($id, $mode, $contentTypes);
|
|
|
|
if ($result['success']) {
|
|
$this->setMessage($result['message']);
|
|
} else {
|
|
$this->setMessage($result['message'], 'error');
|
|
}
|
|
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
}
|
|
|
|
/**
|
|
* Browse articles inside a snapshot — returns JSON for AJAX modal.
|
|
*/
|
|
public function browse(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('id', 0);
|
|
|
|
if (!$id) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
|
->where($db->quoteName('id') . ' = ' . $id);
|
|
$db->setQuery($query);
|
|
$record = $db->loadObject();
|
|
|
|
if (!$record) {
|
|
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($record->status !== 'complete') {
|
|
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
|
|
|
return;
|
|
}
|
|
|
|
$json = file_get_contents($record->data_file);
|
|
|
|
if ($json === false) {
|
|
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
|
|
|
return;
|
|
}
|
|
|
|
$data = json_decode($json, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
|
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
|
|
|
return;
|
|
}
|
|
|
|
$articles = [];
|
|
|
|
foreach ($data['tables']['#__content'] as $row) {
|
|
$articles[] = [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'title' => $row['title'] ?? '',
|
|
'catid' => (int) ($row['catid'] ?? 0),
|
|
'state' => (int) ($row['state'] ?? 0),
|
|
'created' => $row['created'] ?? '',
|
|
];
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'articles' => $articles,
|
|
'total' => count($articles),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Restore selected articles from a snapshot.
|
|
*/
|
|
public function restoreSelected(): void
|
|
{
|
|
$this->checkToken();
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$id = $this->input->getInt('id', 0);
|
|
$articleIds = $this->input->get('article_ids', [], 'array');
|
|
|
|
if (!$id) {
|
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
if (empty($articleIds)) {
|
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SnapshotRestoreEngine();
|
|
$result = $engine->restoreSelectedArticles($id, $articleIds);
|
|
|
|
if ($result['success']) {
|
|
$this->setMessage($result['message']);
|
|
} else {
|
|
$this->setMessage($result['message'], 'error');
|
|
}
|
|
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
}
|
|
|
|
/**
|
|
* Send a JSON response and close the application.
|
|
*/
|
|
private function sendJson(array $data, int $status = 200): void
|
|
{
|
|
$app = $this->app;
|
|
$app->setHeader('status', $status);
|
|
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
$app->sendHeaders();
|
|
|
|
echo json_encode($data);
|
|
|
|
$app->close();
|
|
}
|
|
|
|
/**
|
|
* Delete snapshot records and their data files.
|
|
*/
|
|
public function delete(): void
|
|
{
|
|
$this->checkToken();
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$cid = $this->input->get('cid', [], 'array');
|
|
|
|
if (empty($cid)) {
|
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
|
|
return;
|
|
}
|
|
|
|
$db = Factory::getDbo();
|
|
$deleted = 0;
|
|
$errors = [];
|
|
|
|
foreach ($cid as $id) {
|
|
$id = (int) $id;
|
|
|
|
try {
|
|
// Load record to get file path
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('data_file'))
|
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
|
->where($db->quoteName('id') . ' = ' . $id);
|
|
$db->setQuery($query);
|
|
$dataFile = $db->loadResult();
|
|
|
|
// Delete data file
|
|
if ($dataFile && is_file($dataFile)) {
|
|
if (!unlink($dataFile)) {
|
|
error_log('MokoSuiteBackup: Failed to delete snapshot file: ' . $dataFile);
|
|
}
|
|
}
|
|
|
|
// Delete record
|
|
$query = $db->getQuery(true)
|
|
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
|
|
->where($db->quoteName('id') . ' = ' . $id);
|
|
$db->setQuery($query);
|
|
$db->execute();
|
|
$deleted++;
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: Failed to delete snapshot ' . $id . ': ' . $e->getMessage());
|
|
$errors[] = $id;
|
|
}
|
|
}
|
|
|
|
if (!empty($errors)) {
|
|
$this->setMessage(
|
|
Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted)
|
|
. ' ' . Text::sprintf('COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS', implode(', ', $errors)),
|
|
'warning'
|
|
);
|
|
} else {
|
|
$this->setMessage(Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted));
|
|
}
|
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
|
}
|
|
}
|