fix(restore): clean up staging + session state on cancel/abandon #287
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user