fix(restore): clean up staging + session state on cancel/abandon #287

Merged
jmiller merged 1 commits from fix/restore-cancel-cleanup into dev 2026-07-16 16:47:00 +00:00
5 changed files with 114 additions and 2 deletions
+1
View File
@@ -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
@@ -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
@@ -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.
*/
@@ -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
* `<id>.json` and `<id>.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-<tag>-<id>).
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);
}
}
@@ -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;