diff --git a/CHANGELOG.md b/CHANGELOG.md index e27d182c..d15bc780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Backup- and restore-progress modals (backups view) now have the same controls as the full-screen runbackup screen: an "automatically return to the list when finished" checkbox, a Cancel button (double-confirmed) while running, and terminal action buttons. Backup: View backup record / Back to backups on success, Retry / Back on failure. Restore matches, with a stronger cancel warning (a restore cannot be rolled back mid-flight) and no record link. ### Fixed +- Restore Cancel no longer orphans server-side state. The stepped-restore modal's Cancel button now calls a new `ajax.restoreCancel` action that deletes the restore staging directory and session/state files, and `SteppedSession::cleanupOldSessions()` gained a backstop that reaps orphaned `mokosuitebackup-restore-*` staging dirs (for restores abandoned by closing the browser). Previously a cancelled/abandoned restore left the extracted staging dir behind indefinitely. - Database view "One Problem" on MokoSuiteBackup: reconciled the `#__mokosuitebackup_profiles` schema-checker output with the update-file history. Corrected the stale `TINYINT` definition in `01.01.01.sql` to the final `VARCHAR(20)` type, fixed the `MODIFY COLUMN` parser trip-up in `01.39.00.sql`, and rewrote the retired `sftp_*` `ADD COLUMN` statements in `01.35.00.sql`/`01.36.00.sql` as bare `ADD` so Joomla's checker no longer demands columns that `02.52.25` and the postflight purge deliberately remove. Joomla's schema checker now reports no problems. ### Changed diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index af2b2695..24631366 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -509,6 +509,39 @@ class AjaxController extends BaseController $this->sendJson($result); } + /** + * Cancel an in-progress stepped restore and clean up its staging directory + * and session/state files (prevents orphaned artefacts on a client cancel). + * POST: task=ajax.restoreCancel&session_id=mb_... + */ + public function restoreCancel(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $sessionId = $this->input->getString('session_id', ''); + + if (empty($sessionId)) { + $this->sendJson(['error' => true, 'message' => 'Missing session_id']); + + return; + } + + $engine = new SteppedRestoreEngine(); + $result = $engine->cancel($sessionId); + + $this->sendJson($result); + } + /** * Browse archive contents without extracting. * POST: task=ajax.browseArchive&id=123 diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php index 25668b16..104e2bd0 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php @@ -230,6 +230,39 @@ class SteppedRestoreEngine } } + /** + * Cancel an in-progress restore: delete the staging directory and the + * session/state files so a client-side cancel does not leave orphaned + * artefacts (staging dir + *.restore.json) behind. Best-effort — a missing + * or already-finished session is treated as success. + * + * @param string $sessionId The stepped-restore session id + * + * @return array ['error' => bool, 'message' => string] + */ + public function cancel(string $sessionId): array + { + $restoreState = $this->loadRestoreState($sessionId); + + if ($restoreState !== null) { + $stagingDir = $restoreState['staging_dir'] ?? ''; + + if (!empty($stagingDir) && is_dir($stagingDir)) { + $this->recursiveDelete($stagingDir); + } + } + + $this->destroyRestoreState($sessionId); + + $session = SteppedSession::load($sessionId); + + if ($session) { + $session->destroy(); + } + + return ['error' => false, 'message' => 'Restore cancelled']; + } + /** * Extract phase: extract archive to staging directory. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php index 3a103b35..6711ec30 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php @@ -171,6 +171,11 @@ class SteppedSession /** * Clean up old session files (older than 24 hours). + * + * Reaps backup + restore session state (mb_*.json covers both the + * `.json` and `.restore.json` files) and, as a backstop for + * restores abandoned without a cancel (e.g. the browser was just closed), + * any orphaned restore staging directories left in the tmp folder. */ public static function cleanupOldSessions(): void { @@ -182,10 +187,42 @@ class SteppedSession $cutoff = time() - 86400; - foreach (glob($dir . '/mb_*.json') as $file) { + foreach (glob($dir . '/mb_*.json') ?: [] as $file) { if (filemtime($file) < $cutoff) { @unlink($file); } } + + // Orphaned restore staging directories (mokosuitebackup-restore--). + foreach (glob(JPATH_ROOT . '/tmp/mokosuitebackup-restore-*', GLOB_ONLYDIR) ?: [] as $stagingDir) { + if (@filemtime($stagingDir) < $cutoff) { + self::recursiveDelete($stagingDir); + } + } + } + + /** + * Recursively delete a directory (used to reap orphaned restore staging dirs). + */ + private static function recursiveDelete(string $dir): void + { + if ($dir === '' || !is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + if ($item->isDir()) { + @rmdir($item->getPathname()); + } else { + @unlink($item->getPathname()); + } + } + + @rmdir($dir); } } diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index c561e4ae..eb33797e 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -415,6 +415,7 @@ $listDirn = $this->escape($this->state->get('list.direction')); // --- AJAX stepped restore (controls mirror the backup-progress modal) --- var restoreRunning = false; var restoreCancelled = false; + var restoreSessionId = ''; function showRestoreProgress() { restoreRunning = true; @@ -484,7 +485,12 @@ $listDirn = $this->escape($this->state->get('list.direction')); restoreRunning = false; cancelBtn.disabled = true; document.getElementById('mb-restore-status').textContent = 'Cancelling…'; - leaveRestoreToList(); + // Tell the server to clean up the staging dir + session state, then leave. + if (restoreSessionId) { + postAjax({ task: 'ajax.restoreCancel', session_id: restoreSessionId }).then(leaveRestoreToList, leaveRestoreToList); + } else { + leaveRestoreToList(); + } }); } }); @@ -520,6 +526,7 @@ $listDirn = $this->escape($this->state->get('list.direction')); var password = document.getElementById('mb-restore-password').value; restoreCancelled = false; + restoreSessionId = ''; restoreRunningControls(); setRestoreTitle('icon-refresh', 'Restore in Progress'); showRestoreProgress(); @@ -538,6 +545,7 @@ $listDirn = $this->escape($this->state->get('list.direction')); if (initResult.error) { restoreFailed(initResult.message); return; } var sessionId = initResult.session_id; + restoreSessionId = sessionId; updateRestoreProgress(initResult.progress, initResult.message, initResult.phase); var done = false;