Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php
T
Jonathan Miller 8a4ebe1bde feat: selective article restore from snapshot (#58)
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
2026-06-22 22:18:48 -05:00

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));
}
}