* @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\MokoJoomBackup\Administrator\Controller; defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Session\Session; use Joomla\Component\MokoJoomBackup\Administrator\Engine\SteppedBackupEngine; 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']); 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']); 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']); return; } $requestPath = $this->input->getString('path', JPATH_ROOT); $path = realpath($requestPath) ?: $requestPath; // Security: restrict browsing to site root and current user's home $jRoot = realpath(JPATH_ROOT); $homeDir = getenv('HOME') ?: (getenv('USERPROFILE') ?: ''); $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); 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); } usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name'])); $parent = dirname($path); $this->sendJson([ 'error' => false, 'current' => $path, 'parent' => ($parent !== $path) ? $parent : null, 'dirs' => $dirs, ]); } /** * 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']); return; } $id = $this->input->getInt('id', 0); if (!$id) { $this->sendJson(['error' => true, 'message' => 'Missing record ID']); return; } $db = \Joomla\CMS\Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName(['absolute_path', 'log'])) ->from($db->quoteName('#__mokojoombackup_records')) ->where($db->quoteName('id') . ' = ' . $id); $db->setQuery($query); $record = $db->loadObject(); if (!$record) { $this->sendJson(['error' => true, 'message' => 'Record not found']); return; } // Try to load log from file alongside the archive $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path); $logContent = ''; if (is_file($logPath)) { $logContent = file_get_contents($logPath); } elseif (!empty($record->log)) { // Fall back to database-stored log $logContent = $record->log; } $this->sendJson([ 'error' => false, 'log' => $logContent ?: '(no log available)', 'source' => is_file($logPath) ? 'file' : 'database', ]); } /** * 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']); return; } $rawPath = trim($this->input->getString('path', '')); if ($rawPath === '') { $this->sendJson(['error' => true, 'message' => 'No path provided']); return; } // Resolve [DEFAULT_DIR] placeholder $defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups'; $resolved = str_replace('[DEFAULT_DIR]', $defaultDir, $rawPath); // Resolve relative paths from JPATH_ROOT if ($resolved !== '' && $resolved[0] !== '/' && !preg_match('#^[A-Za-z]:[/\\\\]#', $resolved)) { $resolved = JPATH_ROOT . '/' . $resolved; } // Skip check if unresolved placeholders remain if (preg_match('/\[.+\]/', $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, ]); } /** * Send a JSON response and close the application. */ private function sendJson(array $data): void { $app = $this->app; $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(); } }