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
781 lines
21 KiB
PHP
781 lines
21 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
|
|
*
|
|
* AJAX controller for step-based backups.
|
|
* Handles init and step requests from the admin UI JavaScript.
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\MVC\Controller\BaseController;
|
|
use Joomla\CMS\Session\Session;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
|
|
|
class AjaxController extends BaseController
|
|
{
|
|
/**
|
|
* Initialize a new stepped backup.
|
|
* POST: task=ajax.init&profile_id=1&description=...
|
|
*/
|
|
public function init(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$profileId = $this->input->getInt('profile_id', 1);
|
|
$description = $this->input->getString('description', '');
|
|
|
|
$engine = new SteppedBackupEngine();
|
|
$result = $engine->init($profileId, $description, 'backend');
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Run the next step of a backup session.
|
|
* POST: task=ajax.step&session_id=mb_...
|
|
*/
|
|
public function step(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$sessionId = $this->input->getString('session_id', '');
|
|
|
|
if (empty($sessionId)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SteppedBackupEngine();
|
|
$result = $engine->runStep($sessionId);
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Browse server directories for the folder picker field.
|
|
* POST: task=ajax.browseDir&path=/some/path
|
|
*/
|
|
public function browseDir(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$requestPath = $this->input->getString('path', JPATH_ROOT);
|
|
|
|
// Resolve placeholders and relative paths before permission check
|
|
$resolved = BackupDirectory::resolve($requestPath);
|
|
$path = realpath($resolved) ?: $resolved;
|
|
|
|
// Security: restrict browsing to site root and current user's home
|
|
$jRoot = realpath(JPATH_ROOT);
|
|
$homeDir = BackupDirectory::getHomeDirectory();
|
|
$allowed = false;
|
|
|
|
if ($jRoot !== false && strpos($path, $jRoot) === 0) {
|
|
$allowed = true;
|
|
} elseif ($homeDir !== '' && strpos($path, $homeDir) === 0) {
|
|
$allowed = true;
|
|
}
|
|
|
|
if (!$allowed) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied: path outside allowed directories']);
|
|
return;
|
|
}
|
|
|
|
if (!is_dir($path)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Directory not found: ' . $path]);
|
|
|
|
return;
|
|
}
|
|
|
|
// Security: only allow browsing within JPATH_ROOT or parent directories
|
|
// that could contain a backup folder (e.g., /home/user/backups)
|
|
$dirs = [];
|
|
$handle = @opendir($path);
|
|
$warning = null;
|
|
|
|
if ($handle) {
|
|
while (($entry = readdir($handle)) !== false) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
|
|
$fullPath = $path . '/' . $entry;
|
|
|
|
if (is_dir($fullPath) && $entry[0] !== '.') {
|
|
$dirs[] = [
|
|
'name' => $entry,
|
|
'path' => $fullPath,
|
|
];
|
|
}
|
|
}
|
|
|
|
closedir($handle);
|
|
} else {
|
|
$warning = 'Cannot read directory contents (check permissions)';
|
|
}
|
|
|
|
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
|
|
|
$parent = dirname($path);
|
|
|
|
// Ensure parent is still within allowed boundaries
|
|
$parentAllowed = false;
|
|
|
|
if ($parent !== $path) {
|
|
if ($jRoot !== false && strpos($parent, $jRoot) === 0) {
|
|
$parentAllowed = true;
|
|
} elseif ($homeDir !== '' && (strpos($parent, $homeDir) === 0 || $parent === \dirname($homeDir))) {
|
|
$parentAllowed = true;
|
|
}
|
|
}
|
|
|
|
$response = [
|
|
'error' => false,
|
|
'current' => $path,
|
|
'parent' => $parentAllowed ? $parent : null,
|
|
'dirs' => $dirs,
|
|
];
|
|
|
|
if ($warning !== null) {
|
|
$response['warning'] = $warning;
|
|
}
|
|
|
|
$this->sendJson($response);
|
|
}
|
|
|
|
/**
|
|
* Load and return the log file contents for a backup record.
|
|
* POST: task=ajax.viewLog&id=123
|
|
*/
|
|
public function viewLog(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.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 record ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['absolute_path', 'log']))
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $id);
|
|
$db->setQuery($query);
|
|
$record = $db->loadObject();
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: viewLog() DB error for record ' . $id . ': ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$record) {
|
|
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
// Try to load log from file alongside the archive
|
|
$logPath = BackupDirectory::logPathFromArchive($record->absolute_path);
|
|
$logContent = '';
|
|
$source = 'none';
|
|
|
|
if (is_file($logPath)) {
|
|
$content = file_get_contents($logPath);
|
|
|
|
if ($content !== false) {
|
|
$logContent = $content;
|
|
$source = 'file';
|
|
} else {
|
|
$source = 'file (read error)';
|
|
}
|
|
} elseif (!empty($record->log)) {
|
|
$logContent = $record->log;
|
|
$source = 'database';
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'log' => $logContent ?: '(no log available)',
|
|
'source' => $source,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check directory existence, writability and permissions.
|
|
* POST: task=ajax.checkDir&path=/some/path
|
|
*/
|
|
public function checkDir(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$rawPath = trim($this->input->getString('path', ''));
|
|
|
|
if ($rawPath === '') {
|
|
$this->sendJson(['error' => true, 'message' => 'No path provided']);
|
|
|
|
return;
|
|
}
|
|
|
|
$resolved = BackupDirectory::resolve($rawPath);
|
|
|
|
if (BackupDirectory::hasPlaceholders($resolved)) {
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'exists' => null,
|
|
'writable' => null,
|
|
'resolved' => $resolved,
|
|
'placeholder' => true,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
$exists = is_dir($resolved);
|
|
$writable = $exists && is_writable($resolved);
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'exists' => $exists,
|
|
'writable' => $writable,
|
|
'resolved' => $resolved,
|
|
'placeholder' => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Initialize a new stepped restore.
|
|
* POST: task=ajax.restoreInit&id=123&restore_files=1&restore_db=1&preserve_config=1&encryption_password=
|
|
*/
|
|
public function restoreInit(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$recordId = $this->input->getInt('id', 0);
|
|
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
|
|
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
|
|
$preserveConfig = (bool) $this->input->getInt('preserve_config', 1);
|
|
$password = $this->input->getString('encryption_password', '');
|
|
|
|
if (!$recordId) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SteppedRestoreEngine();
|
|
$result = $engine->init($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Run the next step of a restore session.
|
|
* POST: task=ajax.restoreStep&session_id=mb_...
|
|
*/
|
|
public function restoreStep(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$sessionId = $this->input->getString('session_id', '');
|
|
|
|
if (empty($sessionId)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
|
|
|
|
return;
|
|
}
|
|
|
|
$engine = new SteppedRestoreEngine();
|
|
$result = $engine->runStep($sessionId);
|
|
|
|
$this->sendJson($result);
|
|
}
|
|
|
|
/**
|
|
* Browse archive contents without extracting.
|
|
* POST: task=ajax.browseArchive&id=123
|
|
*/
|
|
public function browseArchive(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.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 record ID']);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['absolute_path', 'status', 'filesexist']))
|
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $id);
|
|
$db->setQuery($query);
|
|
$record = $db->loadObject();
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: browseArchive() DB error for record ' . $id . ': ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$record) {
|
|
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($record->status !== 'complete' || !$record->filesexist) {
|
|
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
|
|
|
|
return;
|
|
}
|
|
|
|
$archivePath = $record->absolute_path;
|
|
|
|
if (!is_file($archivePath)) {
|
|
$this->sendJson(['error' => true, 'message' => 'Archive file not found on disk']);
|
|
|
|
return;
|
|
}
|
|
|
|
$maxEntries = 500;
|
|
|
|
try {
|
|
$files = [];
|
|
$totalFiles = 0;
|
|
$totalSize = 0;
|
|
$truncated = false;
|
|
|
|
$lower = strtolower($archivePath);
|
|
|
|
if (substr($lower, -4) === '.zip') {
|
|
$files = $this->browseZipArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
|
} elseif (substr($lower, -7) === '.tar.gz' || substr($lower, -4) === '.tgz') {
|
|
$files = $this->browseTarArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
|
} else {
|
|
$this->sendJson(['error' => true, 'message' => 'Unsupported archive format']);
|
|
|
|
return;
|
|
}
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: browseArchive() error for record ' . $id . ': ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to read archive: ' . $e->getMessage()]);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'files' => $files,
|
|
'total_files' => $totalFiles,
|
|
'total_size' => $totalSize,
|
|
'truncated' => $truncated,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Browse a ZIP archive and return file entries.
|
|
*
|
|
* @param string $path Absolute path to the ZIP file
|
|
* @param int $maxEntries Maximum entries to return
|
|
* @param int &$totalFiles Total number of files (by reference)
|
|
* @param int &$totalSize Total uncompressed size (by reference)
|
|
* @param bool &$truncated Whether results were truncated (by reference)
|
|
*
|
|
* @return array List of file entry arrays
|
|
*/
|
|
private function browseZipArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
|
{
|
|
$zip = new \ZipArchive();
|
|
|
|
if ($zip->open($path, \ZipArchive::RDONLY) !== true) {
|
|
throw new \RuntimeException('Cannot open ZIP archive');
|
|
}
|
|
|
|
$files = [];
|
|
$totalFiles = $zip->numFiles;
|
|
|
|
for ($i = 0; $i < $totalFiles; $i++) {
|
|
$stat = $zip->statIndex($i);
|
|
|
|
if ($stat === false) {
|
|
continue;
|
|
}
|
|
|
|
$totalSize += $stat['size'];
|
|
|
|
if (\count($files) < $maxEntries) {
|
|
$files[] = [
|
|
'name' => $stat['name'],
|
|
'size' => $stat['size'],
|
|
'compressed_size' => $stat['comp_size'],
|
|
];
|
|
}
|
|
}
|
|
|
|
$truncated = $totalFiles > $maxEntries;
|
|
$zip->close();
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Browse a tar.gz archive and return file entries.
|
|
*
|
|
* @param string $path Absolute path to the tar.gz file
|
|
* @param int $maxEntries Maximum entries to return
|
|
* @param int &$totalFiles Total number of files (by reference)
|
|
* @param int &$totalSize Total uncompressed size (by reference)
|
|
* @param bool &$truncated Whether results were truncated (by reference)
|
|
*
|
|
* @return array List of file entry arrays
|
|
*/
|
|
private function browseTarArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
|
{
|
|
$phar = new \PharData($path);
|
|
$files = [];
|
|
|
|
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
|
|
$totalFiles++;
|
|
$entrySize = $entry->getSize();
|
|
$totalSize += $entrySize;
|
|
|
|
if (\count($files) < $maxEntries) {
|
|
// Strip the phar:// prefix and archive path to get relative name
|
|
$fullPath = str_replace('\\', '/', $entry->getPathname());
|
|
$relativeName = preg_replace('#^phar://.+?\.tar\.gz/#i', '', $fullPath)
|
|
?: preg_replace('#^phar://.+?\.tgz/#i', '', $fullPath)
|
|
?: $fullPath;
|
|
|
|
$files[] = [
|
|
'name' => $relativeName,
|
|
'size' => $entrySize,
|
|
'compressed_size' => $entrySize,
|
|
];
|
|
}
|
|
}
|
|
|
|
$truncated = $totalFiles > $maxEntries;
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Browse articles inside a snapshot — returns JSON list for the browse modal.
|
|
* POST: task=ajax.browseSnapshot&id=123
|
|
*/
|
|
public function browseSnapshot(): 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 = \Joomla\CMS\Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $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),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Compare two backup records side-by-side.
|
|
* POST: task=ajax.compareBackups&id1=123&id2=456
|
|
*/
|
|
public function compareBackups(): void
|
|
{
|
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
|
|
|
return;
|
|
}
|
|
|
|
$id1 = $this->input->getInt('id1', 0);
|
|
$id2 = $this->input->getInt('id2', 0);
|
|
|
|
if (!$id1 || !$id2) {
|
|
$this->sendJson(['error' => true, 'message' => 'Two backup record IDs are required']);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($id1 === $id2) {
|
|
$this->sendJson(['error' => true, 'message' => 'Please select two different backup records']);
|
|
|
|
return;
|
|
}
|
|
|
|
$fields = [
|
|
'r.id', 'r.description', 'r.status', 'r.backup_type',
|
|
'r.total_size', 'r.db_size', 'r.files_count', 'r.tables_count',
|
|
'r.backupstart', 'r.backupend',
|
|
];
|
|
|
|
try {
|
|
$db = \Joomla\CMS\Factory::getDbo();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName($fields))
|
|
->select($db->quoteName('p.title', 'profile_title'))
|
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p')
|
|
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id'))
|
|
->where($db->quoteName('r.id') . ' IN (' . (int) $id1 . ', ' . (int) $id2 . ')');
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadObjectList('id');
|
|
} catch (\Exception $e) {
|
|
error_log('MokoSuiteBackup: compareBackups() DB error: ' . $e->getMessage());
|
|
$this->sendJson(['error' => true, 'message' => 'Failed to load backup records'], 500);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!isset($rows[$id1])) {
|
|
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id1 . ' not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!isset($rows[$id2])) {
|
|
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id2 . ' not found'], 404);
|
|
|
|
return;
|
|
}
|
|
|
|
$b1 = $rows[$id1];
|
|
$b2 = $rows[$id2];
|
|
|
|
// Calculate durations in seconds
|
|
$duration1 = 0;
|
|
$duration2 = 0;
|
|
|
|
if ($b1->backupstart !== '0000-00-00 00:00:00' && $b1->backupend !== '0000-00-00 00:00:00') {
|
|
$duration1 = strtotime($b1->backupend) - strtotime($b1->backupstart);
|
|
}
|
|
|
|
if ($b2->backupstart !== '0000-00-00 00:00:00' && $b2->backupend !== '0000-00-00 00:00:00') {
|
|
$duration2 = strtotime($b2->backupend) - strtotime($b2->backupstart);
|
|
}
|
|
|
|
$formatRecord = function ($row) {
|
|
return [
|
|
'id' => (int) $row->id,
|
|
'description' => $row->description,
|
|
'status' => $row->status,
|
|
'backup_type' => $row->backup_type,
|
|
'total_size' => (int) $row->total_size,
|
|
'db_size' => (int) $row->db_size,
|
|
'files_count' => (int) $row->files_count,
|
|
'tables_count' => (int) $row->tables_count,
|
|
'backupstart' => $row->backupstart,
|
|
'backupend' => $row->backupend,
|
|
'profile_title' => $row->profile_title ?? '',
|
|
];
|
|
};
|
|
|
|
$this->sendJson([
|
|
'error' => false,
|
|
'backup1' => $formatRecord($b1),
|
|
'backup2' => $formatRecord($b2),
|
|
'delta' => [
|
|
'size_diff' => (int) $b2->total_size - (int) $b1->total_size,
|
|
'files_diff' => (int) $b2->files_count - (int) $b1->files_count,
|
|
'tables_diff' => (int) $b2->tables_count - (int) $b1->tables_count,
|
|
'duration_diff_seconds' => $duration2 - $duration1,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|