Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php
T
jmiller 288baf41d3
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 30s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
fix: remove duplicate version tags from 8 manifests, align AjaxController to params column
CI version_bump was creating duplicate <version> lines in all
sub-extension manifests. Also AjaxController still referenced the old
`config` column and removed `keep_local` column on the remotes table.

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-26 21:19:21 -05:00

1401 lines
38 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\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver;
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;
}
/* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
$profileId = $this->input->getInt('profile_id', 0);
if ($profileId > 0) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
}
if (empty($profile)) {
/* No profile context — create a minimal dummy for PlaceholderResolver */
$profile = (object) [
'id' => 1,
'title' => 'default',
'backup_type' => 'full',
];
}
$resolver = new PlaceholderResolver($profile);
$withNamePlaceholders = $resolver->resolve($rawPath);
$resolved = BackupDirectory::resolve($withNamePlaceholders);
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.restore', '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.restore', '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('mokosuitebackup.backup.browse', '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) {
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
return;
}
$tables = $data['tables'] ?? [];
// Articles
$articles = [];
if (!empty($tables['#__content'])) {
foreach ($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'] ?? '',
];
}
}
// Categories
$categories = [];
if (!empty($tables['#__categories'])) {
foreach ($tables['#__categories'] as $row) {
$categories[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'extension' => $row['extension'] ?? '',
'published' => (int) ($row['published'] ?? 0),
'level' => (int) ($row['level'] ?? 0),
];
}
}
// Modules
$modules = [];
if (!empty($tables['#__modules'])) {
foreach ($tables['#__modules'] as $row) {
$modules[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'module' => $row['module'] ?? '',
'position' => $row['position'] ?? '',
'published' => (int) ($row['published'] ?? 0),
];
}
}
$this->sendJson([
'error' => false,
'articles' => $articles,
'categories' => $categories,
'modules' => $modules,
'total_articles' => \count($articles),
'total_categories' => \count($categories),
'total_modules' => \count($modules),
]);
}
/**
* Count backup records that would be purged before a given date.
* POST: task=ajax.countPurge&date=2025-01-01
*/
public function countPurge(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$date = $this->input->getString('date', '');
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$this->sendJson(['error' => true, 'message' => 'Invalid date format. Expected YYYY-MM-DD.']);
return;
}
$cutoff = $date . ' 00:00:00';
try {
$db = \Joomla\CMS\Factory::getDbo();
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$count = (int) $db->loadResult();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: countPurge() DB error: ' . $e->getMessage());
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
return;
}
$this->sendJson([
'error' => false,
'count' => $count,
'date' => $date,
]);
}
/**
* 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('mokosuitebackup.backup.compare', '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,
],
]);
}
// ------------------------------------------------------------------
// Remote Destinations CRUD
// ------------------------------------------------------------------
/**
* List remote destinations for a profile.
* POST: task=ajax.listRemotes&profile_id=1
*/
public function listRemotes(): 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;
}
$profileId = $this->input->getInt('profile_id', 0);
if (!$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
return;
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . $profileId)
->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList();
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
return;
}
// Decode JSON params and mask secrets
$items = [];
foreach ($rows as $row) {
$config = json_decode($row->params, true) ?: [];
// Mask sensitive fields so they never leave the server in list views
$masked = $this->maskSecrets($config, $row->type);
$items[] = [
'id' => (int) $row->id,
'profile_id' => (int) $row->profile_id,
'title' => $row->title,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'params' => $masked,
'ordering' => (int) $row->ordering,
];
}
$this->sendJson(['error' => false, 'items' => $items]);
}
/**
* Save (create or update) a remote destination.
* POST: task=ajax.saveRemote (JSON body or form fields)
*/
public function saveRemote(): 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('remote_id', 0);
$profileId = $this->input->getInt('profile_id', 0);
$title = trim($this->input->getString('remote_title', ''));
$type = $this->input->getCmd('remote_type', 'sftp');
$enabled = $this->input->getInt('remote_enabled', 1);
$configRaw = $this->input->getString('remote_config', '{}');
if (!$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
return;
}
if (empty($title)) {
$this->sendJson(['error' => true, 'message' => 'Title is required']);
return;
}
$config = json_decode($configRaw, true);
if (!is_array($config)) {
$this->sendJson(['error' => true, 'message' => 'Invalid config JSON']);
return;
}
// If editing, merge secrets that were masked with __KEEP_EXISTING__
if ($id) {
$config = $this->mergeExistingSecrets($id, $config, $type);
}
$db = Factory::getDbo();
try {
$table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db);
if ($id) {
$table->load($id);
// Verify ownership
if ((int) $table->profile_id !== $profileId) {
$this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403);
return;
}
}
$table->profile_id = $profileId;
$table->title = $title;
$table->type = $type;
$table->enabled = $enabled ? 1 : 0;
$table->params = json_encode($config);
if (!$table->check() || !$table->store()) {
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
return;
}
$this->sendJson(['error' => false, 'id' => (int) $table->id, 'message' => 'Saved']);
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error: ' . $e->getMessage()], 500);
}
}
/**
* Delete a remote destination.
* POST: task=ajax.deleteRemote&remote_id=1&profile_id=1
*/
public function deleteRemote(): 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('remote_id', 0);
$profileId = $this->input->getInt('profile_id', 0);
if (!$id || !$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
return;
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('id') . ' = ' . $id)
->where($db->quoteName('profile_id') . ' = ' . $profileId);
$db->setQuery($query);
$db->execute();
$this->sendJson(['error' => false, 'message' => 'Deleted']);
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
}
}
/**
* Toggle enabled/disabled for a remote destination.
* POST: task=ajax.toggleRemote&remote_id=1&profile_id=1
*/
public function toggleRemote(): 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('remote_id', 0);
$profileId = $this->input->getInt('profile_id', 0);
if (!$id || !$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
return;
}
try {
$db = Factory::getDbo();
// Load current state
$query = $db->getQuery(true)
->select($db->quoteName('enabled'))
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('id') . ' = ' . $id)
->where($db->quoteName('profile_id') . ' = ' . $profileId);
$db->setQuery($query);
$current = $db->loadResult();
if ($current === null) {
$this->sendJson(['error' => true, 'message' => 'Remote not found'], 404);
return;
}
$newState = $current ? 0 : 1;
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_remotes'))
->set($db->quoteName('enabled') . ' = ' . $newState)
->set($db->quoteName('modified') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
->where($db->quoteName('id') . ' = ' . $id)
->where($db->quoteName('profile_id') . ' = ' . $profileId);
$db->setQuery($update);
$db->execute();
$this->sendJson(['error' => false, 'enabled' => $newState]);
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
}
}
/**
* Mask sensitive values in a remote config array for display.
*/
private function maskSecrets(array $config, string $type): array
{
$secrets = [
'sftp' => ['password', 'passphrase', 'key_data'],
's3' => ['secret_key'],
'google_drive' => ['client_secret', 'refresh_token'],
];
$fields = $secrets[$type] ?? [];
foreach ($fields as $field) {
if (!empty($config[$field])) {
$config[$field] = '********';
}
}
return $config;
}
/**
* When updating a remote, merge back secrets that were masked in the form.
*/
private function mergeExistingSecrets(int $id, array $config, string $type): array
{
$secrets = [
'sftp' => ['password', 'passphrase', 'key_data'],
's3' => ['secret_key'],
'google_drive' => ['client_secret', 'refresh_token'],
];
$fields = $secrets[$type] ?? [];
$needsMerge = false;
foreach ($fields as $field) {
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
$needsMerge = true;
break;
}
}
if (!$needsMerge) {
return $config;
}
// Load existing config from DB
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$existing = json_decode($db->loadResult() ?: '{}', true) ?: [];
} catch (\Exception $e) {
return $config;
}
foreach ($fields as $field) {
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
$config[$field] = $existing[$field] ?? '';
}
}
return $config;
}
/**
* Browse directories on a remote SFTP server for the path picker.
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
*/
public function browseSftpDir(): 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;
}
$profileId = $this->input->getInt('profile_id', 0);
if (!$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
return;
}
/* Load the profile to get SFTP credentials */
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
return;
}
if (!$profile) {
$this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
return;
}
$host = $profile->sftp_host ?? '';
$port = (int) ($profile->sftp_port ?? 22);
$username = $profile->sftp_username ?? '';
$keyData = $profile->sftp_key_data ?? '';
$password = $profile->sftp_password ?? '';
if (empty($host) || empty($username)) {
$this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']);
return;
}
if (empty($keyData) && empty($password)) {
$this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
return;
}
$requestPath = $this->input->getString('path', '/');
/* Sanitize: must start with / and not contain shell meta-characters */
$requestPath = '/' . ltrim($requestPath, '/');
if (preg_match('/[;&|`$<>]/', $requestPath)) {
$this->sendJson(['error' => true, 'message' => 'Invalid path characters']);
return;
}
$keyFile = null;
try {
/* Write temp key if using key auth (same pattern as SftpUploader) */
if (!empty($keyData)) {
$keyContent = base64_decode($keyData, true);
if ($keyContent === false) {
$keyContent = $keyData;
}
$keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
if (file_put_contents($keyFile, $keyContent) === false) {
throw new \RuntimeException('Cannot write temporary SSH key file');
}
chmod($keyFile, 0600);
}
/* Build SSH command to list directories */
$escapedPath = escapeshellarg($requestPath);
$remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"';
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
if ($port !== 22) {
$parts[] = '-p';
$parts[] = (string) $port;
}
if ($keyFile !== null) {
$parts[] = '-i';
$parts[] = escapeshellarg($keyFile);
}
$parts[] = escapeshellarg($username . '@' . $host);
$parts[] = escapeshellarg($remoteCmd);
$cmd = implode(' ', $parts);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
/* exitCode 1 from grep means no matches (empty dir), which is OK */
if ($exitCode !== 0 && $exitCode !== 1) {
throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output));
}
/* Parse output: each line is a directory name ending with / */
$dirs = [];
foreach ($output as $line) {
$line = trim($line);
if ($line === '' || $line === './' || $line === '../') {
continue;
}
$dirName = rtrim($line, '/');
if ($dirName === '' || $dirName === '.' || $dirName === '..') {
continue;
}
$fullPath = rtrim($requestPath, '/') . '/' . $dirName;
$dirs[] = [
'name' => $dirName,
'path' => $fullPath,
];
}
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
/* Parent path */
$parent = null;
if ($requestPath !== '/') {
$parent = \dirname($requestPath);
if ($parent === '') {
$parent = '/';
}
}
$this->sendJson([
'error' => false,
'current' => $requestPath,
'parent' => $parent,
'dirs' => $dirs,
]);
} catch (\Throwable $e) {
$this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
} finally {
if ($keyFile !== null && is_file($keyFile)) {
unlink($keyFile);
}
}
}
/**
* 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();
}
}