feat: pre-update backup notice + live-progress modal on update pages
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Generic: Project CI / Lint & Validate (pull_request) Successful in 15s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 48s
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

The server-side onExtensionBeforeUpdate backup runs synchronously and
cannot drive a browser modal, so nothing ever 'popped'. Add a client
notice + on-demand live-progress modal on com_joomlaupdate and
com_installer(view=update):

- plg_system_mokosuitebackup gains onBeforeCompileHead, which injects a
  'back up before you update' notice + Back up now button on the update
  pages (gated by show_update_notice + backup_before_update), plus a new
  media JS asset that runs the same stepped backup as the dashboard
  (ajax.init then a loop of ajax.step) with a dependency-free progress
  overlay.
- New AjaxController::preupdateAck() arms the same 10-min throttle key
  runPreActionBackup() checks, so proceeding with Joomla's update after a
  modal backup does not run a duplicate server-side backup.
- Plugin manifest registers the new media/js folder; language keys added.

Phase-1 baseline (notice + on-demand). Auto-intercepting the Update
button is the planned phase-2 follow-up (#196).

Refs #196

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
This commit is contained in:
2026-07-05 20:56:31 -05:00
parent 4027c727d9
commit 8dc4eaa384
7 changed files with 326 additions and 0 deletions
+1
View File
@@ -3,6 +3,7 @@
## [Unreleased]
### Added
- Pre-update backup **notice + live-progress modal** on the admin update pages (Joomla Update and Extensions → Update). Because the server-side `onExtensionBeforeUpdate` backup runs synchronously and can't drive a browser modal, the system plugin now injects a "back up before you update" notice with a **Back up now** button that runs the same stepped backup as the dashboard and shows a live progress bar. On success it pings a new `ajax.preupdateAck` endpoint, which arms the same throttle the server-side hook checks — so clicking Joomla's Update afterwards won't run a duplicate backup. Gated by the existing `show_update_notice` + `backup_before_update` params. (Auto-intercepting the Update button is the planned phase-2 follow-up — see #196.) (#196)
- Retention now prunes **remote** copies too: when a backup is pruned by age/count, its archive is deleted from every enabled remote destination (SFTP / FTP / S3 / Google Drive), not just the local copy. Each uploader gained an idempotent `delete()` method (already-absent file = success), and removal is best-effort — a failing destination is logged but never blocks local pruning. The shared standalone `restore.php` is intentionally left in place (every backup overwrites it, so newer backups still depend on it). (#229)
### Changed
@@ -84,6 +84,35 @@ class AjaxController extends BaseController
$this->sendJson($result);
}
/**
* Mark the pre-update backup as satisfied for this session.
*
* Called by the update-page modal after a successful on-demand backup so the
* imminent server-side onExtensionBeforeUpdate backup is skipped — it sets
* the same 10-minute throttle key the system plugin checks, preventing a
* duplicate backup when the admin then clicks Joomla's Update button.
* POST: task=ajax.preupdateAck
*/
public function preupdateAck(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
// Same key + semantics as plg_system_mokosuitebackup::runPreActionBackup().
Factory::getSession()->set('mokosuitebackup.preaction_backup_before_update', time());
$this->sendJson(['error' => false, 'message' => 'Pre-update backup acknowledged']);
}
/**
* Cancel a backup record stuck in "running" status.
* POST: task=ajax.cancelBackup&id=123
@@ -7,3 +7,10 @@ PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE="Max Backup Age (days)"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE_DESC="Delete backup records older than this many days."
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS="Max Backup Count"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS_DESC="Keep at most this many completed backup records."
PLG_SYSTEM_MOKOJOOMBACKUP_UPDATE_NOTICE="MokoSuiteBackup: a full-site backup is recommended before you update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_NOW="Back up now"
PLG_SYSTEM_MOKOJOOMBACKUP_DISMISS="Dismiss"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP="Backing up…"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup complete — safe to update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED="Backup failed"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING="Starting backup…"
@@ -7,3 +7,10 @@ PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE="Max Backup Age (days)"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE_DESC="Delete backup records older than this many days."
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS="Max Backup Count"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS_DESC="Keep at most this many completed backup records."
PLG_SYSTEM_MOKOJOOMBACKUP_UPDATE_NOTICE="MokoSuiteBackup: a full-site backup is recommended before you update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_NOW="Back up now"
PLG_SYSTEM_MOKOJOOMBACKUP_DISMISS="Dismiss"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP="Backing up…"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup complete — safe to update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED="Backup failed"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING="Starting backup…"
@@ -0,0 +1,202 @@
/**
* MokoSuiteBackup — pre-update backup notice + live-progress modal.
*
* Injected by plg_system_mokosuitebackup on the Joomla core-update
* (com_joomlaupdate) and extensions-update (com_installer&view=update) pages.
*
* Why a client-side modal: the server-side onExtensionBeforeUpdate backup runs
* synchronously mid-request and cannot drive a browser modal. This offers an
* on-demand "Back up now" button that runs the same stepped backup the
* dashboard uses (ajax.init then a loop of ajax.step) and shows live progress.
* On success it pings ajax.preupdateAck so the imminent server-side pre-update
* backup is skipped (no duplicate backup) when the admin clicks Update.
*
* Config comes from Joomla.getOptions('plg_system_mokosuitebackup.preupdate').
*/
(function () {
'use strict';
var cfg = (window.Joomla && Joomla.getOptions)
? Joomla.getOptions('plg_system_mokosuitebackup.preupdate', null)
: null;
if (!cfg || !cfg.ajaxUrl || !cfg.token) {
return;
}
var L = cfg.labels || {};
var running = false;
document.addEventListener('DOMContentLoaded', function () {
injectNotice();
});
function injectNotice() {
var host = document.querySelector('main') || document.querySelector('#content') || document.body;
if (!host || document.getElementById('msb-preupdate-notice')) {
return;
}
var bar = document.createElement('div');
bar.id = 'msb-preupdate-notice';
bar.setAttribute('role', 'status');
bar.style.cssText = 'display:flex;align-items:center;gap:1rem;flex-wrap:wrap;'
+ 'margin:0 0 1rem 0;padding:.75rem 1rem;border:1px solid #f0c36d;'
+ 'background:#fcf8e3;border-radius:.35rem;color:#8a6d3b;font-size:.95rem;';
var text = document.createElement('span');
text.style.cssText = 'flex:1 1 auto;';
text.textContent = L.notice || 'A full-site backup is recommended before you update.';
var btn = document.createElement('button');
btn.type = 'button';
btn.id = 'msb-preupdate-run';
btn.className = 'btn btn-warning btn-sm';
btn.textContent = L.backupNow || 'Back up now';
btn.addEventListener('click', runBackup);
var dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'btn btn-link btn-sm';
dismiss.style.cssText = 'color:#8a6d3b;';
dismiss.textContent = L.dismiss || 'Dismiss';
dismiss.addEventListener('click', function () { bar.remove(); });
bar.appendChild(text);
bar.appendChild(btn);
bar.appendChild(dismiss);
host.insertBefore(bar, host.firstChild);
}
/* ── The live-progress overlay (dependency-free, mirrors the dashboard) ── */
function buildOverlay() {
var overlay = document.createElement('div');
overlay.id = 'msb-preupdate-overlay';
overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;'
+ 'align-items:center;justify-content:center;background:rgba(0,0,0,.5);';
var box = document.createElement('div');
box.style.cssText = 'width:min(480px,92vw);background:#fff;border-radius:.5rem;'
+ 'padding:1.5rem;box-shadow:0 10px 40px rgba(0,0,0,.3);';
var title = document.createElement('h3');
title.id = 'msb-pu-title';
title.style.cssText = 'margin:0 0 1rem 0;font-size:1.15rem;';
title.textContent = L.backingUp || 'Backing up…';
var track = document.createElement('div');
track.style.cssText = 'height:1.25rem;background:#e9ecef;border-radius:.35rem;overflow:hidden;';
var barfill = document.createElement('div');
barfill.id = 'msb-pu-bar';
barfill.style.cssText = 'height:100%;width:0;background:#198754;transition:width .3s ease;';
track.appendChild(barfill);
var status = document.createElement('div');
status.id = 'msb-pu-status';
status.style.cssText = 'margin-top:.75rem;font-size:.9rem;color:#555;';
status.textContent = L.starting || 'Starting backup…';
box.appendChild(title);
box.appendChild(track);
box.appendChild(status);
overlay.appendChild(box);
document.body.appendChild(overlay);
return { overlay: overlay, title: title, bar: barfill, status: status };
}
function setProgress(ui, progress, message) {
var pct = Math.max(0, Math.min(100, parseInt(progress, 10) || 0));
ui.bar.style.width = pct + '%';
if (message) {
ui.status.textContent = message;
}
}
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(); });
}
async function runBackup() {
if (running) {
return;
}
running = true;
var runBtn = document.getElementById('msb-preupdate-run');
if (runBtn) {
runBtn.disabled = true;
}
var ui = buildOverlay();
var guard = function (e) { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', guard);
try {
var init = await post({ task: 'ajax.init', profile_id: cfg.profileId });
if (!init || init.error || !init.session_id) {
throw new Error((init && init.message) || 'Could not start backup');
}
setProgress(ui, init.progress, init.message);
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');
}
setProgress(ui, step.progress, step.message);
done = step.done || false;
}
/* Tell the server the pre-update backup is satisfied so the
synchronous onExtensionBeforeUpdate backup is skipped. */
try { await post({ task: 'ajax.preupdateAck' }); } catch (ignore) {}
ui.title.textContent = L.complete || 'Backup complete — safe to update.';
setProgress(ui, 100, '');
var notice = document.getElementById('msb-preupdate-notice');
if (notice) {
notice.style.background = '#d1e7dd';
notice.style.borderColor = '#badbcc';
notice.style.color = '#0f5132';
}
setTimeout(function () { ui.overlay.remove(); }, 1500);
} catch (err) {
ui.title.textContent = (L.failed || 'Backup failed') + ': ' + err.message;
ui.bar.style.background = '#dc3545';
if (runBtn) {
runBtn.disabled = false;
}
} finally {
running = false;
window.removeEventListener('beforeunload', guard);
}
}
})();
@@ -29,6 +29,10 @@
<language tag="en-GB">language/en-GB/plg_system_mokosuitebackup.sys.ini</language>
</languages>
<media destination="plg_system_mokosuitebackup" folder="media">
<folder>js</folder>
</media>
<config>
<fields name="params">
<fieldset name="basic">
@@ -13,8 +13,13 @@ namespace Joomla\Plugin\System\MokoSuiteBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Document\HtmlDocument;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Uri\Uri;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RetentionManager;
use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner;
use Joomla\Event\Event;
@@ -29,6 +34,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
return [
'onAfterInitialise' => 'onAfterInitialise',
'onAfterRoute' => 'onAfterRoute',
'onBeforeCompileHead' => 'onBeforeCompileHead',
'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate',
'onExtensionBeforeUninstall' => 'onExtensionBeforeUninstall',
];
@@ -139,6 +145,76 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$this->cleanupOldSnapshots();
}
/**
* Inject the pre-update backup notice + live-progress modal on the admin
* update pages (com_joomlaupdate and com_installer's update view).
*
* The server-side onExtensionBeforeUpdate backup runs synchronously and
* cannot drive a browser modal, so this offers an on-demand "Back up now"
* button that runs the same stepped backup as the dashboard and shows live
* progress. On success the client pings ajax.preupdateAck, which arms the
* same throttle key runPreActionBackup() checks — so the subsequent server
* update does not run a duplicate backup.
*/
public function onBeforeCompileHead(Event $event): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator')) {
return;
}
$input = $app->getInput();
$option = $input->getCmd('option', '');
$view = $input->getCmd('view', '');
$onUpdatePage = $option === 'com_joomlaupdate'
|| ($option === 'com_installer' && $view === 'update');
if (!$onUpdatePage) {
return;
}
$params = ComponentHelper::getParams('com_mokosuitebackup');
// Respect the notice toggle and the pre-update backup feature flag.
if (!(int) $params->get('show_update_notice', 1) || !(int) $params->get('backup_before_update', 0)) {
return;
}
// Already backed up this session (within the throttle window)? Stay quiet.
$lastRun = (int) Factory::getSession()->get('mokosuitebackup.preaction_backup_before_update', 0);
if ($lastRun > 0 && (time() - $lastRun) < 600) {
return;
}
$doc = $app->getDocument();
if (!$doc instanceof HtmlDocument) {
return;
}
$doc->getWebAssetManager()->useScript('core');
$doc->addScriptOptions('plg_system_mokosuitebackup.preupdate', [
'ajaxUrl' => Route::_('index.php?option=com_mokosuitebackup&format=json', false),
'token' => Session::getFormToken(),
'profileId' => (int) $params->get('default_profile', 1),
'labels' => [
'notice' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_UPDATE_NOTICE'),
'backupNow' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_NOW'),
'dismiss' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_DISMISS'),
'backingUp' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP'),
'starting' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING'),
'complete' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE'),
'failed' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED'),
],
]);
$doc->addScript(Uri::root(true) . '/media/plg_system_mokosuitebackup/js/update-backup.js', [], ['defer' => true]);
}
/**
* Remove backup records and files per profile retention settings.
* Each profile can override the global max_age_days and max_backups.