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