diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 159af3b..a867273 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -84,6 +84,24 @@ class AjaxController extends BaseController $this->sendJson($result); } + /** + * Mark that the JS-driven pre-update backup has completed so the + * server-side onExtensionBeforeUpdate handler skips its own run. + * POST: task=ajax.markPreUpdateDone + */ + public function markPreUpdateDone(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + Factory::getSession()->set('mokosuitebackup.preupdate_js_done', true); + + $this->sendJson(['success' => true]); + } + /** * Browse server directories for the folder picker field. * POST: task=ajax.browseDir&path=/some/path diff --git a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php index 1e7406a..6c3d16a 100644 --- a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php +++ b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php @@ -28,6 +28,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface return [ 'onAfterInitialise' => 'onAfterInitialise', 'onAfterRoute' => 'onAfterRoute', + 'onBeforeRender' => 'onBeforeRender', 'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate', 'onExtensionBeforeUninstall' => 'onExtensionBeforeUninstall', ]; @@ -347,10 +348,52 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface } /** - * Run a backup before any extension is updated. + * Inject JavaScript on installer/update pages to show a backup progress + * modal before extension updates proceed. + */ + public function onBeforeRender(Event $event): void + { + $app = $this->getApplication(); + + if (!$app->isClient('administrator')) { + return; + } + + $option = $app->input->getCmd('option', ''); + $view = $app->input->getCmd('view', ''); + + if ($option !== 'com_installer' && $option !== 'com_joomlaupdate') { + return; + } + + $params = ComponentHelper::getParams('com_mokosuitebackup'); + + if (!(int) $params->get('backup_before_update', 0)) { + return; + } + + $profileId = (int) $params->get('default_profile', 1); + $token = \Joomla\CMS\Session\Session::getFormToken(); + + $js = $this->getPreUpdateBackupScript($profileId, $token); + + $app->getDocument()->addScriptDeclaration($js); + } + + /** + * Run a backup before any extension is updated (server-side fallback + * for CLI/API updates where JavaScript is not available). */ public function onExtensionBeforeUpdate(Event $event): void { + $session = Factory::getSession(); + + if ($session->get('mokosuitebackup.preupdate_js_done', false)) { + $session->set('mokosuitebackup.preupdate_js_done', false); + + return; + } + $this->runPreActionBackup('backup_before_update', 'Pre-update backup'); } @@ -408,6 +451,161 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface } } + /** + * Build the inline JavaScript that intercepts extension update actions + * and runs a stepped backup with a progress modal first. + */ + private function getPreUpdateBackupScript(int $profileId, string $token): string + { + $baseUrl = \Joomla\CMS\Uri\Uri::base() . 'index.php?option=com_mokosuitebackup&format=json&' . $token . '=1'; + + return <<' + + '' + + '' + + ''; + document.body.appendChild(msbModal); + + var bsModal = new bootstrap.Modal(msbModal); + + function msbUpdateProgress(pct, msg) { + var bar = document.getElementById('msbProgressBar'); + bar.style.width = pct + '%'; + bar.textContent = pct + '%'; + if (msg) document.getElementById('msbStatusText').textContent = msg; + } + + function msbLog(msg) { + var log = document.getElementById('msbLogArea'); + log.textContent += msg + '\\n'; + log.scrollTop = log.scrollHeight; + } + + function msbShowFooter() { + document.getElementById('msbFooter').style.display = ''; + } + + function msbFinish(success) { + msbBackupRunning = false; + if (success && msbPendingTask) { + msbUpdateProgress(100, 'Backup complete — proceeding with update...'); + // Mark JS backup done so server-side handler skips + fetch('{$baseUrl}&task=ajax.markPreUpdateDone', {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}}); + setTimeout(function() { + bsModal.hide(); + msbOrigSubmit.call(Joomla, msbPendingTask); + msbPendingTask = null; + }, 800); + } + } + + function msbRunStep(sessionId) { + fetch('{$baseUrl}&task=ajax.step&session_id=' + encodeURIComponent(sessionId), { + method: 'POST', + headers: {'X-Requested-With': 'XMLHttpRequest'} + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.error) { + msbUpdateProgress(data.progress || 0, 'Backup error: ' + data.message); + msbLog('ERROR: ' + data.message); + msbShowFooter(); + return; + } + msbUpdateProgress(data.progress || 0, data.message || data.phase || 'Working...'); + if (data.message) msbLog(data.message); + if (data.done) { + msbFinish(true); + } else { + msbRunStep(sessionId); + } + }) + .catch(function(err) { + msbUpdateProgress(0, 'Backup request failed'); + msbLog('Network error: ' + err.message); + msbShowFooter(); + }); + } + + function msbStartBackup() { + msbBackupRunning = true; + msbUpdateProgress(0, 'Initializing backup...'); + msbLog('Starting pre-update backup (profile {$profileId})...'); + + fetch('{$baseUrl}&task=ajax.init&profile_id={$profileId}&description=Pre-update+backup', { + method: 'POST', + headers: {'X-Requested-With': 'XMLHttpRequest'} + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.error) { + msbUpdateProgress(0, 'Backup init failed: ' + data.message); + msbLog('INIT ERROR: ' + data.message); + msbShowFooter(); + return; + } + msbLog('Backup initialized — ' + data.message); + msbUpdateProgress(data.progress || 5, data.message || 'Running...'); + msbRunStep(data.session_id); + }) + .catch(function(err) { + msbUpdateProgress(0, 'Could not start backup'); + msbLog('Network error: ' + err.message); + msbShowFooter(); + }); + } + + // Intercept Joomla toolbar submit + Joomla.submitbutton = function(task) { + if ((task === 'update.update' || task === 'update.install') && !msbBackupRunning) { + msbPendingTask = task; + bsModal.show(); + msbStartBackup(); + return; + } + msbOrigSubmit.call(Joomla, task); + }; + + // Skip button — proceed without backup + document.getElementById('msbSkipBtn').addEventListener('click', function() { + bsModal.hide(); + msbBackupRunning = false; + if (msbPendingTask) { + msbOrigSubmit.call(Joomla, msbPendingTask); + msbPendingTask = null; + } + }); + + // Cancel button — abort everything + document.getElementById('msbCancelBtn').addEventListener('click', function() { + bsModal.hide(); + msbBackupRunning = false; + msbPendingTask = null; + }); +}); +JS; + } + /** * Send a JSON response and terminate — used by web cron handler. */