a2362d5400
Generic: Project CI / Tests (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 8s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 7s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
feat(runbackup): opt-in auto-continue + manual continue on pre-update screen The pre-update/uninstall full-screen backup now shows an "Automatically continue the update when the backup finishes" checkbox (ticked by default, so the seamless flow is unchanged). If unticked, the screen stops on completion and offers a link to the new backup record plus a "Continue the update" button, so the user can hand back to Joomla manually — a reliable fallback when auto-continue does not fire. Also drop the hard-to-read `small` text class from the progress screen. Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i @
329 lines
12 KiB
PHP
329 lines
12 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;
|
|
|
|
/* Reject any backslash or control char outright: legitimate Joomla admin
|
|
return URLs never contain them, and a browser normalises a leading "/\"
|
|
to "//", turning a would-be root-relative path into a protocol-relative
|
|
open redirect (e.g. "/\evil.com" → "//evil.com"). */
|
|
if (preg_match('/[\x00-\x1f\x7f\\\\]/', $raw)) {
|
|
$raw = '';
|
|
}
|
|
|
|
$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,
|
|
'action' => $this->action,
|
|
'dashboardUrl' => $dashboardUrl,
|
|
'recordUrl' => Route::_('index.php?option=com_mokosuitebackup&view=backup&id=__MSBID__', false),
|
|
'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'),
|
|
'viewRecord' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_VIEW_RECORD'),
|
|
'leaveWarn' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING'),
|
|
'continueUpdate' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_UPDATE'),
|
|
],
|
|
];
|
|
|
|
// Screen CSS lives in an external media stylesheet (media/com_mokosuitebackup/
|
|
// css/runbackup.css), not an inline <style> block. It is registered in
|
|
// media/joomla.asset.json, but this component's Web Asset Manager styles do not
|
|
// emit on the admin document (the asset resolves without error yet no <link> is
|
|
// produced — the same is true of com_mokosuitebackup.admin), so we attach the
|
|
// stylesheet directly to the document, which reliably renders via <jdoc:include
|
|
// type="styles" />.
|
|
$this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbackup');
|
|
$this->getDocument()->addStyleSheet(Uri::root(true) . '/media/com_mokosuitebackup/css/runbackup.css');
|
|
?>
|
|
<div class="msb-runbackup">
|
|
<div class="card shadow-sm bg-body-secondary">
|
|
<div class="card-body p-4 pb-3">
|
|
<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 id="msb-rb-phase" class="fw-bold mb-1"></div>
|
|
<div id="msb-rb-status" class="mb-0"><?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), ENT_QUOTES, 'UTF-8'); ?></div>
|
|
|
|
<?php if ($safeReturnUrl !== '') : ?>
|
|
<!-- Pre-update/uninstall flow: opt in to auto-continue, or get a manual "Continue" button on completion. -->
|
|
<div class="form-check mt-3" id="msb-rb-autocontinue-wrap">
|
|
<input class="form-check-input" type="checkbox" id="msb-rb-autocontinue" checked>
|
|
<label class="form-check-label" for="msb-rb-autocontinue">
|
|
<?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_AUTOCONTINUE'), ENT_QUOTES, 'UTF-8'); ?>
|
|
</label>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div id="msb-rb-actions" class="d-none mt-3">
|
|
<button type="button" id="msb-rb-retry" class="btn btn-primary d-none"></button>
|
|
<a href="#" id="msb-rb-record" class="btn btn-primary d-none"></a>
|
|
<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>
|
|
|
|
<!-- Full-bleed progress bar, edge-to-edge along the bottom of the card. -->
|
|
<div class="msb-rb-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
|
<div id="msb-rb-bar" class="msb-rb-bar is-animated">0%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
var CFG = <?php echo json_encode($config); ?>;
|
|
var L = CFG.labels || {};
|
|
var running = false;
|
|
var lastRecordId = 0;
|
|
|
|
var el = {
|
|
bar: document.getElementById('msb-rb-bar'),
|
|
track: document.querySelector('.msb-rb-progress'),
|
|
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'),
|
|
record: document.getElementById('msb-rb-record'),
|
|
continue: document.getElementById('msb-rb-continue'),
|
|
dashboard: document.getElementById('msb-rb-dashboard'),
|
|
autocontinue: document.getElementById('msb-rb-autocontinue'),
|
|
autocontinueWrap: document.getElementById('msb-rb-autocontinue-wrap')
|
|
};
|
|
|
|
function setBar(pct, striped) {
|
|
pct = Math.max(0, Math.min(100, parseInt(pct, 10) || 0));
|
|
el.bar.style.width = pct + '%';
|
|
el.bar.textContent = pct + '%';
|
|
if (el.track) { el.track.setAttribute('aria-valuenow', pct); }
|
|
if (!striped) {
|
|
el.bar.classList.remove('is-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');
|
|
}
|
|
|
|
async function onComplete() {
|
|
el.bar.classList.remove('is-animated');
|
|
el.bar.classList.add('is-success');
|
|
setBar(100, false);
|
|
el.title.textContent = L.complete || 'Backup complete';
|
|
|
|
/* Set the one-shot skip flag for THIS action so the following
|
|
server-side onExtensionBefore(Update|Uninstall) backup is skipped once
|
|
(no duplicate backup) without suppressing later, distinct actions.
|
|
Awaited so it lands before we hand back to the update/uninstall. */
|
|
try { await post({ task: 'ajax.preupdateAck', action: CFG.action || '' }); } catch (e) {}
|
|
|
|
if (CFG.returnUrl) {
|
|
/* Pre-update / pre-uninstall flow. If "auto-continue" is ticked
|
|
(default) hand back to Joomla automatically; otherwise stop here
|
|
and offer a link to the new backup record plus a manual
|
|
"Continue the update" button. */
|
|
if (el.autocontinueWrap) { el.autocontinueWrap.classList.add('d-none'); }
|
|
|
|
var autoContinue = !el.autocontinue || el.autocontinue.checked;
|
|
|
|
if (autoContinue) {
|
|
setStatus(L.continuing || 'Continuing…');
|
|
window.setTimeout(function () { window.location.href = CFG.returnUrl; }, 1200);
|
|
} else {
|
|
setStatus(L.complete || 'Backup complete');
|
|
showActions();
|
|
|
|
if (lastRecordId > 0 && CFG.recordUrl) {
|
|
showBtn(el.record, L.viewRecord || 'View backup record',
|
|
CFG.recordUrl.replace('__MSBID__', String(lastRecordId)));
|
|
}
|
|
|
|
el.continue.classList.remove('btn-warning');
|
|
el.continue.classList.add('btn-success');
|
|
showBtn(el.continue, L.continueUpdate || 'Continue the update', CFG.returnUrl);
|
|
}
|
|
} else {
|
|
/* Manual "Backup Now": offer to view the record that was just made. */
|
|
setStatus(L.complete || 'Backup complete');
|
|
showActions();
|
|
|
|
if (lastRecordId > 0 && CFG.recordUrl) {
|
|
showBtn(el.record, L.viewRecord || 'View backup record',
|
|
CFG.recordUrl.replace('__MSBID__', String(lastRecordId)));
|
|
}
|
|
|
|
showBtn(el.dashboard, L.dashboard || 'Back to dashboard', CFG.dashboardUrl);
|
|
}
|
|
}
|
|
|
|
function onError(message) {
|
|
running = false;
|
|
el.bar.classList.remove('is-animated');
|
|
el.bar.classList.add('is-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('is-danger');
|
|
el.bar.classList.add('is-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…'));
|
|
if (init.record_id) { lastRecordId = init.record_id; }
|
|
|
|
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 || '');
|
|
if (step.record_id) { lastRecordId = step.record_id; }
|
|
done = step.done || false;
|
|
}
|
|
|
|
running = false;
|
|
await 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>
|