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 @
296 lines
11 KiB
PHP
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>
|