41b481dbfe
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Joomla: Extension CI / Release Readiness Check (pull_request) Has been cancelled
Joomla: Extension CI / Lint & Validate (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Universal: Build & Release / Promote to RC (pull_request) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
- Update .htaccess content to support both Apache 2.4 (Require all denied) and Apache 2.2 (Order deny,allow) in all four locations - Guard browseDir parent navigation to prevent escaping allowed boundaries - Add explicit (int) cast on viewLog SQL query for defense-in-depth
291 lines
7.2 KiB
PHP
291 lines
7.2 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoJoomBackup
|
|
* @subpackage com_mokojoombackup
|
|
* @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\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);
|
|
$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) {
|
|
$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']);
|
|
|
|
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') . ' = ' . (int) $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;
|
|
}
|
|
|
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokojoombackup')) {
|
|
$this->sendJson(['error' => true, 'message' => 'Access denied']);
|
|
|
|
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();
|
|
}
|
|
}
|