Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/tmpl/runbackup/default.php
T
jmiller f5bcc7c457
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 17s
@
fix(css): reliably load component stylesheets (progress bar was unstyled)

The full-screen backup progress bar lost all styling after the CSS moved
to the Web Asset Manager: this component's WAM style assets do not emit a
<link> on the admin document. The asset resolves without error (Joomla
lazily registers media/com_mokosuitebackup/joomla.asset.json, so useStyle
does not throw) yet no stylesheet tag is rendered — and the same was
already silently true of com_mokosuitebackup.admin (it was just cosmetic,
so unnoticed). Confirmed live: neither runbackup.css nor admin.css loads,
body stays white, the bar has no track/fill.

Attach the external media stylesheets directly to the document
(addStyleSheet), which renders reliably via <jdoc:include type="styles">.
The joomla.asset.json + useStyle calls are kept so the WAM path resumes
automatically if/when it starts emitting. Fixes both runbackup and the
profile edit screen.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
2026-07-12 19:05:15 -05:00

296 lines
11 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'),
],
];
// 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">
<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">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>