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
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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user