3eb2562abd
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
fix: pre-action backup fires for every extension, not one per 10 min Root cause of "pre-update backup only fires on the backup extension": a coarse 600s session throttle. Whenever ANY pre-action backup ran (including updating MokoSuiteBackup itself, or a core Joomla update), the key mokosuitebackup.preaction_backup_before_update was armed for 10 minutes. onBeforeCompileHead read it as `recentBackup` and DISABLED the client-side interceptor for all extensions, and runPreActionBackup was throttled by the same window. So after updating one extension, every other extension update silently skipped its backup for 10 minutes. Replace the time window with "one backup per Update action": - runPreActionBackup dedupes per-request (a batch update fires the event once per extension in a single request -> back up once) via instance flags, and consumes a ONE-SHOT session skip flag for the re-fired client update/uninstall (skip once, next distinct action backs up). - ajax.preupdateAck sets the one-shot flag for the SPECIFIC action (update|uninstall), passed from installer-backup.js via &msb_action and surfaced by Runbackup HtmlView -> CFG.action. A core update passes no action, so it never suppresses an extension backup. - onBeforeCompileHead drops the recentBackup time-suppression (the re-fire loop is already guarded client-side by window.__msbResuming). - The core Joomla update path uses its own key (mokosuitebackup.core_update_backed_up), decoupled from extensions. Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i @
332 lines
12 KiB
PHP
332 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'),
|
|
],
|
|
];
|
|
?>
|
|
<style>
|
|
/* Colours are pulled from the Atum admin template's own CSS custom
|
|
properties so the screen matches whatever theme the site runs, with
|
|
Bootstrap and then a literal as progressive fallbacks. */
|
|
.msb-runbackup {
|
|
--msb-accent: var(--template-link-color, var(--bs-link-color, #2a69b8));
|
|
--msb-accent2: var(--template-special-color, var(--msb-accent));
|
|
--msb-success: var(--template-success-color, var(--bs-success, #2f7d32));
|
|
--msb-danger: var(--template-danger-color, var(--bs-danger, #c52128));
|
|
--msb-track: var(--template-bg-light, var(--bs-gray-200, #edf0f5));
|
|
padding: 0 1rem;
|
|
}
|
|
/* overflow:hidden clips the full-bleed bar to the card's rounded corners. */
|
|
.msb-runbackup .card { overflow: hidden; }
|
|
.msb-rb-progress {
|
|
height: 2.5rem;
|
|
background: var(--msb-track);
|
|
overflow: hidden;
|
|
}
|
|
.msb-rb-bar {
|
|
height: 100%;
|
|
width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #fff;
|
|
font-weight: 600;
|
|
font-size: .95rem;
|
|
white-space: nowrap;
|
|
background-color: var(--msb-accent);
|
|
background-image: linear-gradient(90deg, var(--msb-accent), var(--msb-accent2));
|
|
transition: width .35s ease;
|
|
}
|
|
.msb-rb-bar.is-animated::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image: linear-gradient(45deg, rgba(255,255,255,.18) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.18) 50%, rgba(255,255,255,.18) 75%, transparent 75%, transparent);
|
|
background-size: 1rem 1rem;
|
|
animation: msb-rb-stripes 1s linear infinite;
|
|
}
|
|
.msb-rb-bar { position: relative; }
|
|
.msb-rb-bar.is-success { background-image: none; background-color: var(--msb-success); }
|
|
.msb-rb-bar.is-danger { background-image: none; background-color: var(--msb-danger); }
|
|
@keyframes msb-rb-stripes { from { background-position: 1rem 0; } to { background-position: 0 0; } }
|
|
</style>
|
|
<div class="msb-runbackup">
|
|
<div class="card shadow-sm">
|
|
<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="text-muted small mb-0"><?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), ENT_QUOTES, 'UTF-8'); ?></div>
|
|
|
|
<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" style="width:0;">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')
|
|
};
|
|
|
|
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: continue the original action.
|
|
No "view record" button here — we're handing back to Joomla. */
|
|
setStatus(L.continuing || 'Continuing…');
|
|
window.setTimeout(function () { window.location.href = CFG.returnUrl; }, 1200);
|
|
} 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>
|