4d476fde5d
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Generic: Project CI / Lint & Validate (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Require Docs Update (pull_request) Has been cancelled
Universal: PR Check / Wiki Update Reminder (pull_request) Has been cancelled
Universal: PR Check / Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Has been cancelled
Branch Cleanup / Delete merged branch (pull_request) Has been cancelled
RC Revert / Rename rc/ back to dev/ (pull_request) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
style(runbackup): drop max-width/margin so the card spans full width The backup screen was centred in a 720px column; unset max-width and the auto margins so it fills the available width. Keeps the horizontal padding. Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i @
322 lines
11 KiB
PHP
322 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;
|
|
|
|
$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'),
|
|
],
|
|
];
|
|
?>
|
|
<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';
|
|
|
|
/* 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('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>
|