Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/tmpl/runbackup/default.php
T
jmiller 7a0636606e
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
feat: full-screen backup for extension update/uninstall + view-record button
Extend the full-screen backup screen beyond core Joomla updates:

- Extension updates/uninstalls (com_installer): update.update and
  manage.remove are POST + checkToken with a checked cid[] list, so a
  server-side GET redirect can't resume them. New client-side interceptor
  (installer-backup.js, injected via onBeforeCompileHead on the update and
  manage views) wraps Joomla.submitbutton, stores the selection, sends the
  browser to view=runbackup, and on return restores the selection and
  re-submits the real POST form. Gated by backup_before_update /
  backup_before_uninstall, super-user only, skipped within the throttle
  window.

- runbackup: on manual 'Backup Now' completion, show a 'View backup
  record' button (links to view=backup&id=<record_id>); not shown for the
  pre-update/uninstall flow (which hands back to Joomla). SteppedBackup
  Engine now returns record_id in its step result.

- runbackup arms the pre-action throttles on success (ajax.preupdateAck,
  now arming both update+uninstall keys) so the following server-side
  onExtensionBefore(Update|Uninstall) backup is skipped (no duplicate).

- Plugin manifest re-registers media/js.

NOTE: untested end-to-end (no dev build available this session) — verify
the com_installer resume flow on a dev build before relying on it.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-10 10:53:46 -05:00

275 lines
9.6 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,
'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'),
],
];
?>
<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-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>
</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'),
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 + '%';
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');
}
async 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';
/* Arm the pre-action throttles so a following server-side onExtension
Before(Update|Uninstall) backup is skipped (no duplicate backup).
Awaited so it lands before we hand back to the update/uninstall. */
try { await post({ task: 'ajax.preupdateAck' }); } 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('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…'));
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>