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