diff --git a/CHANGELOG.md b/CHANGELOG.md index cb620142..0733ee39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 9d568ad7..cda1f601 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -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 diff --git a/source/packages/plg_system_mokosuitebackup/language/en-GB/plg_system_mokosuitebackup.ini b/source/packages/plg_system_mokosuitebackup/language/en-GB/plg_system_mokosuitebackup.ini index d0804cd2..a465eb30 100644 --- a/source/packages/plg_system_mokosuitebackup/language/en-GB/plg_system_mokosuitebackup.ini +++ b/source/packages/plg_system_mokosuitebackup/language/en-GB/plg_system_mokosuitebackup.ini @@ -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…" diff --git a/source/packages/plg_system_mokosuitebackup/language/en-US/plg_system_mokosuitebackup.ini b/source/packages/plg_system_mokosuitebackup/language/en-US/plg_system_mokosuitebackup.ini index 15103fbb..03a35287 100644 --- a/source/packages/plg_system_mokosuitebackup/language/en-US/plg_system_mokosuitebackup.ini +++ b/source/packages/plg_system_mokosuitebackup/language/en-US/plg_system_mokosuitebackup.ini @@ -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…" diff --git a/source/packages/plg_system_mokosuitebackup/media/js/update-backup.js b/source/packages/plg_system_mokosuitebackup/media/js/update-backup.js new file mode 100644 index 00000000..493b45ea --- /dev/null +++ b/source/packages/plg_system_mokosuitebackup/media/js/update-backup.js @@ -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); + } + } +})(); diff --git a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml index ad232c6c..2aa8e82d 100644 --- a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml @@ -29,6 +29,10 @@ language/en-GB/plg_system_mokosuitebackup.sys.ini + + js + +
diff --git a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php index 6ec0c2d6..21690564 100644 --- a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php +++ b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php @@ -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.