4311ec8f99
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Project CI / Lint & Validate (pull_request) Successful in 14s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m8s
Generic: Project CI / Tests (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
The pre-update return URL is assigned to window.location.href, so a javascript:/data: URI (empty host) would have passed the host-only check and executed. Require an absolute http(s) same-host URL or a single-slash root-relative path; reject javascript:/data:/vbscript: and protocol-relative //host. Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
254 lines
8.5 KiB
PHP
254 lines
8.5 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
|
|
*
|
|
* Full-screen backup progress screen. Reuses the stepped-backup AJAX
|
|
* (ajax.init → loop ajax.step). On completion, redirects to a validated
|
|
* return URL (pre-update flow) or shows a completion panel (Backup Now).
|
|
*/
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Language\Text;
|
|
use Joomla\CMS\Router\Route;
|
|
use Joomla\CMS\Session\Session;
|
|
use Joomla\CMS\Uri\Uri;
|
|
|
|
$ajaxToken = Session::getFormToken();
|
|
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
|
|
|
|
/* Validate the return URL to prevent an open redirect / javascript: XSS
|
|
(the value ends up in window.location.href). Accept ONLY:
|
|
- an absolute http(s) URL whose host matches this site, or
|
|
- a root-relative path starting with a single "/" (not "//").
|
|
This rejects javascript:, data:, vbscript: (empty host) and
|
|
protocol-relative //evil.com. Falls back to '' (no redirect). */
|
|
$safeReturnUrl = '';
|
|
|
|
if ($this->returnUrl !== '') {
|
|
$decoded = base64_decode($this->returnUrl, true);
|
|
$raw = ($decoded !== false && $decoded !== '') ? $decoded : $this->returnUrl;
|
|
|
|
$isAbsoluteHttp = (bool) preg_match('#^https?://#i', $raw);
|
|
$isRootRelative = isset($raw[0]) && $raw[0] === '/' && (!isset($raw[1]) || $raw[1] !== '/');
|
|
|
|
if ($isAbsoluteHttp || $isRootRelative) {
|
|
try {
|
|
$rootHost = Uri::getInstance(Uri::root())->getHost();
|
|
$target = new Uri($raw);
|
|
$scheme = strtolower((string) $target->getScheme());
|
|
|
|
$schemeOk = $scheme === '' || $scheme === 'http' || $scheme === 'https';
|
|
$hostOk = $target->getHost() === '' || strcasecmp($target->getHost(), $rootHost) === 0;
|
|
|
|
if ($schemeOk && $hostOk) {
|
|
$safeReturnUrl = $raw;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$safeReturnUrl = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
$dashboardUrl = Route::_('index.php?option=com_mokosuitebackup&view=dashboard', false);
|
|
|
|
$config = [
|
|
'ajaxUrl' => $ajaxUrl,
|
|
'token' => $ajaxToken,
|
|
'profileId' => $this->profileId,
|
|
'description' => $this->description,
|
|
'returnUrl' => $safeReturnUrl,
|
|
'dashboardUrl' => $dashboardUrl,
|
|
'autostart' => $this->autostart,
|
|
'labels' => [
|
|
'starting' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'),
|
|
'running' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_RUNNING'),
|
|
'complete' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_COMPLETE'),
|
|
'continuing' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUING'),
|
|
'failed' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED'),
|
|
'retry' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_RETRY'),
|
|
'continue' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_ANYWAY'),
|
|
'dashboard' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_BACK_TO_DASHBOARD'),
|
|
'leaveWarn' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING'),
|
|
],
|
|
];
|
|
?>
|
|
<div class="msb-runbackup" style="max-width:640px;margin:3rem auto;padding:0 1rem;">
|
|
<div class="card shadow-sm">
|
|
<div class="card-body p-4">
|
|
<h1 class="h4 mb-1" id="msb-rb-title">
|
|
<span class="icon-archive" aria-hidden="true"></span>
|
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE'); ?>
|
|
</h1>
|
|
<p class="text-muted mb-4" id="msb-rb-subtitle">
|
|
<?php echo htmlspecialchars(
|
|
$this->profileTitle !== ''
|
|
? Text::sprintf('COM_MOKOJOOMBACKUP_RUNBACKUP_PROFILE', $this->profileTitle)
|
|
: Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'),
|
|
ENT_QUOTES,
|
|
'UTF-8'
|
|
); ?>
|
|
</p>
|
|
|
|
<div class="progress mb-3" style="height:1.75rem;">
|
|
<div id="msb-rb-bar" class="progress-bar progress-bar-striped progress-bar-animated"
|
|
role="progressbar" style="width:0;" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">0%</div>
|
|
</div>
|
|
|
|
<div id="msb-rb-phase" class="fw-bold mb-1"></div>
|
|
<div id="msb-rb-status" class="text-muted small mb-3"><?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), ENT_QUOTES, 'UTF-8'); ?></div>
|
|
|
|
<div id="msb-rb-actions" class="d-none">
|
|
<button type="button" id="msb-rb-retry" class="btn btn-primary d-none"></button>
|
|
<a href="#" id="msb-rb-continue" class="btn btn-warning d-none"></a>
|
|
<a href="<?php echo $dashboardUrl; ?>" id="msb-rb-dashboard" class="btn btn-secondary d-none"></a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
var CFG = <?php echo json_encode($config); ?>;
|
|
var L = CFG.labels || {};
|
|
var running = false;
|
|
|
|
var el = {
|
|
bar: document.getElementById('msb-rb-bar'),
|
|
phase: document.getElementById('msb-rb-phase'),
|
|
status: document.getElementById('msb-rb-status'),
|
|
title: document.getElementById('msb-rb-title'),
|
|
actions: document.getElementById('msb-rb-actions'),
|
|
retry: document.getElementById('msb-rb-retry'),
|
|
continue: document.getElementById('msb-rb-continue'),
|
|
dashboard: document.getElementById('msb-rb-dashboard')
|
|
};
|
|
|
|
function setBar(pct, striped) {
|
|
pct = Math.max(0, Math.min(100, parseInt(pct, 10) || 0));
|
|
el.bar.style.width = pct + '%';
|
|
el.bar.textContent = pct + '%';
|
|
el.bar.setAttribute('aria-valuenow', pct);
|
|
if (!striped) {
|
|
el.bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
}
|
|
}
|
|
function setPhase(t) { el.phase.textContent = t || ''; }
|
|
function setStatus(t) { if (t) { el.status.textContent = t; } }
|
|
|
|
function post(params) {
|
|
var body = new URLSearchParams();
|
|
body.append(CFG.token, '1');
|
|
Object.keys(params).forEach(function (k) { body.append(k, params[k]); });
|
|
return fetch(CFG.ajaxUrl, {
|
|
method: 'POST',
|
|
body: body,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
}).then(function (r) { return r.json(); });
|
|
}
|
|
|
|
function showActions() { el.actions.classList.remove('d-none'); }
|
|
function showBtn(node, label, href) {
|
|
node.textContent = label;
|
|
if (href) { node.setAttribute('href', href); }
|
|
node.classList.remove('d-none');
|
|
}
|
|
|
|
function onComplete() {
|
|
el.bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
el.bar.classList.add('bg-success');
|
|
setBar(100, false);
|
|
el.title.textContent = L.complete || 'Backup complete';
|
|
|
|
if (CFG.returnUrl) {
|
|
setStatus(L.continuing || 'Continuing…');
|
|
window.setTimeout(function () { window.location.href = CFG.returnUrl; }, 1200);
|
|
} else {
|
|
setStatus(L.complete || 'Backup complete');
|
|
showActions();
|
|
showBtn(el.dashboard, L.dashboard || 'Back to dashboard', CFG.dashboardUrl);
|
|
}
|
|
}
|
|
|
|
function onError(message) {
|
|
running = false;
|
|
el.bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
el.bar.classList.add('bg-danger');
|
|
el.title.textContent = L.failed || 'Backup failed';
|
|
setStatus(message || 'Backup failed');
|
|
showActions();
|
|
|
|
el.retry.textContent = L.retry || 'Retry';
|
|
el.retry.classList.remove('d-none');
|
|
el.retry.onclick = function () {
|
|
el.retry.classList.add('d-none');
|
|
el.continue.classList.add('d-none');
|
|
el.bar.classList.remove('bg-danger');
|
|
el.bar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
run();
|
|
};
|
|
|
|
if (CFG.returnUrl) {
|
|
showBtn(el.continue, L.continue || 'Continue without backup', CFG.returnUrl);
|
|
}
|
|
showBtn(el.dashboard, L.dashboard || 'Back to dashboard', CFG.dashboardUrl);
|
|
}
|
|
|
|
async function run() {
|
|
if (running) { return; }
|
|
running = true;
|
|
el.actions.classList.add('d-none');
|
|
setBar(0, true);
|
|
setPhase('');
|
|
setStatus(L.starting || 'Starting backup…');
|
|
|
|
try {
|
|
var init = await post({ task: 'ajax.init', profile_id: CFG.profileId, description: CFG.description });
|
|
|
|
if (!init || init.error || !init.session_id) {
|
|
throw new Error((init && init.message) || 'Could not start backup');
|
|
}
|
|
|
|
setBar(init.progress, true);
|
|
setPhase(init.phase || '');
|
|
setStatus(init.message || (L.running || 'Backing up…'));
|
|
|
|
var done = false;
|
|
while (!done) {
|
|
var step = await post({ task: 'ajax.step', session_id: init.session_id });
|
|
|
|
if (!step || step.error) {
|
|
throw new Error((step && step.message) || 'Backup step failed');
|
|
}
|
|
|
|
setBar(step.progress, true);
|
|
setPhase(step.phase || '');
|
|
setStatus(step.message || '');
|
|
done = step.done || false;
|
|
}
|
|
|
|
running = false;
|
|
onComplete();
|
|
} catch (err) {
|
|
onError(err && err.message ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
/* Warn before leaving while a backup is mid-flight. */
|
|
window.addEventListener('beforeunload', function (e) {
|
|
if (running) { e.preventDefault(); e.returnValue = L.leaveWarn || ''; }
|
|
});
|
|
|
|
if (CFG.autostart) {
|
|
document.addEventListener('DOMContentLoaded', run);
|
|
}
|
|
})();
|
|
</script>
|