diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4ec11..983d384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Added +- AJAX-based stepped restore engine for large sites — prevents timeout on shared hosting (#62) +- Email/ntfy notifications for site restores and snapshot create/restore operations (#60) +- Scheduled task type `mokosuitebackup.snapshot` for automated content snapshots via com_scheduler (#56) + ## [01.31.00] --- 2026-06-22 ## [01.31.00] --- 2026-06-22 diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 2cdbbb7..bff38b2 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -18,6 +18,7 @@ defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Session\Session; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; class AjaxController extends BaseController @@ -308,6 +309,74 @@ class AjaxController extends BaseController ]); } + /** + * Initialize a new stepped restore. + * POST: task=ajax.restoreInit&id=123&restore_files=1&restore_db=1&preserve_config=1&encryption_password= + */ + public function restoreInit(): 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; + } + + $recordId = $this->input->getInt('id', 0); + $restoreFiles = (bool) $this->input->getInt('restore_files', 1); + $restoreDb = (bool) $this->input->getInt('restore_db', 1); + $preserveConfig = (bool) $this->input->getInt('preserve_config', 1); + $password = $this->input->getString('encryption_password', ''); + + if (!$recordId) { + $this->sendJson(['error' => true, 'message' => 'Missing record ID']); + + return; + } + + $engine = new SteppedRestoreEngine(); + $result = $engine->init($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password); + + $this->sendJson($result); + } + + /** + * Run the next step of a restore session. + * POST: task=ajax.restoreStep&session_id=mb_... + */ + public function restoreStep(): 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; + } + + $sessionId = $this->input->getString('session_id', ''); + + if (empty($sessionId)) { + $this->sendJson(['error' => true, 'message' => 'Missing session_id']); + + return; + } + + $engine = new SteppedRestoreEngine(); + $result = $engine->runStep($sessionId); + + $this->sendJson($result); + } + /** * Send a JSON response and close the application. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php new file mode 100644 index 0000000..cf1b9ef --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php @@ -0,0 +1,753 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * AJAX step-based restore engine for shared hosting. + * + * Each call to runStep() performs one unit of work within the PHP time + * limit, saves state, and returns. The browser JS fires the next step. + * + * Phases: extract -> files -> database -> config -> cleanup -> complete + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class SteppedRestoreEngine +{ + /** + * Number of files to copy per step during the files phase. + */ + private const FILE_BATCH_SIZE = 200; + + /** + * Number of SQL statements to execute per step during the database phase. + */ + private const SQL_BATCH_SIZE = 500; + + /** + * Initialize a new stepped restore session. + * + * @param int $recordId Backup record ID to restore from + * @param bool $restoreFiles Whether to restore files + * @param bool $restoreDb Whether to restore the database + * @param bool $preserveConfig Keep current configuration.php + * @param string $password Decryption password (for encrypted archives) + * + * @return array{session_id: string, phase: string, progress: int, message: string} + */ + public function init(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true, string $password = ''): array + { + if (!extension_loaded('zip')) { + return ['error' => true, 'message' => 'PHP ext-zip is required for restore operations']; + } + + $db = Factory::getDbo(); + + // Load backup record + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('id') . ' = ' . $recordId); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + return ['error' => true, 'message' => 'Backup record not found: ' . $recordId]; + } + + if ($record->status !== 'complete') { + return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')']; + } + + $archivePath = $record->absolute_path; + + if (!is_file($archivePath) || !is_readable($archivePath)) { + return ['error' => true, 'message' => 'Backup archive not found: ' . $archivePath]; + } + + // Create session + $session = SteppedSession::create(); + $session->recordId = $recordId; + $session->archivePath = $archivePath; + $session->archiveName = basename($archivePath); + $session->description = 'Restore from: ' . ($record->description ?: basename($archivePath)); + + // Store restore-specific settings as dynamic properties via the session's + // generic save/load (SteppedSession serialises all public properties). + // We repurpose some existing fields and add restore-specific ones to the + // session data stored on disk. + $session->phase = 'extract'; + + // Build staging directory path + $safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore'); + $stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag . '-' . substr($session->sessionId, 3); + + // Estimate total steps + $totalSteps = 1; // extract step + + if ($restoreFiles) { + $totalSteps += 1; // at least one files step (will adjust after extraction) + } + + if ($restoreDb) { + $totalSteps += 1; // at least one database step (will adjust after extraction) + } + + $totalSteps += 1; // config step + $totalSteps += 1; // cleanup step + + $session->totalSteps = $totalSteps; + $session->currentStep = 0; + $session->statusMessage = 'Initializing restore...'; + + // Store restore-specific data in session log metadata + // We'll use a JSON file alongside the session for restore state + $restoreState = [ + 'staging_dir' => $stagingDir, + 'restore_files' => $restoreFiles, + 'restore_db' => $restoreDb, + 'preserve_config' => $preserveConfig, + 'password' => $password, + 'config_backup' => '', + 'file_list' => [], + 'file_index' => 0, + 'sql_file' => '', + 'sql_offset' => 0, + 'sql_done' => false, + 'sql_executed' => 0, + ]; + + $this->saveRestoreState($session->sessionId, $restoreState); + + $session->log('Restore initialized for record #' . $recordId . ': ' . $record->description); + $session->log('Archive: ' . $archivePath); + $session->log('Options: files=' . ($restoreFiles ? 'yes' : 'no') + . ', database=' . ($restoreDb ? 'yes' : 'no') + . ', preserve_config=' . ($preserveConfig ? 'yes' : 'no')); + $session->save(); + + return [ + 'session_id' => $session->sessionId, + 'phase' => $session->phase, + 'progress' => $session->getProgress(), + 'message' => $session->statusMessage, + ]; + } + + /** + * Run the next step of a restore session. + * + * @return array{session_id: string, phase: string, progress: int, message: string, done?: bool} + */ + public function runStep(string $sessionId): array + { + $session = SteppedSession::load($sessionId); + + if (!$session) { + return ['error' => true, 'message' => 'Session not found: ' . $sessionId]; + } + + $restoreState = $this->loadRestoreState($sessionId); + + if (!$restoreState) { + return ['error' => true, 'message' => 'Restore state not found for session: ' . $sessionId]; + } + + try { + switch ($session->phase) { + case 'extract': + $this->stepExtract($session, $restoreState); + break; + + case 'files': + $this->stepFiles($session, $restoreState); + break; + + case 'database': + $this->stepDatabase($session, $restoreState); + break; + + case 'config': + $this->stepConfig($session, $restoreState); + break; + + case 'cleanup': + $this->stepCleanup($session, $restoreState); + break; + + case 'complete': + $this->destroyRestoreState($sessionId); + $session->destroy(); + + return [ + 'session_id' => $sessionId, + 'phase' => 'complete', + 'progress' => 100, + 'message' => 'Restore complete: ' . $session->archiveName, + 'done' => true, + ]; + } + + $this->saveRestoreState($sessionId, $restoreState); + $session->save(); + + return [ + 'session_id' => $sessionId, + 'phase' => $session->phase, + 'progress' => $session->getProgress(), + 'message' => $session->statusMessage, + 'done' => $session->phase === 'complete', + ]; + } catch (\Throwable $e) { + $session->log('FATAL: ' . $e->getMessage()); + + // Restore config on failure if we preserved it + if (!empty($restoreState['config_backup']) && $restoreState['preserve_config']) { + @file_put_contents(JPATH_ROOT . '/configuration.php', $restoreState['config_backup']); + $session->log('Configuration.php restored after failure'); + } + + // Clean up staging on failure + $stagingDir = $restoreState['staging_dir'] ?? ''; + + if (!empty($stagingDir) && is_dir($stagingDir)) { + $this->recursiveDelete($stagingDir); + } + + $this->destroyRestoreState($sessionId); + $session->destroy(); + + return ['error' => true, 'message' => 'Restore failed: ' . $e->getMessage()]; + } + } + + /** + * Extract phase: extract archive to staging directory. + */ + private function stepExtract(SteppedSession $session, array &$state): void + { + $stagingDir = $state['staging_dir']; + $archivePath = $session->archivePath; + $password = $state['password']; + + // Clean existing staging dir + if (is_dir($stagingDir)) { + $this->recursiveDelete($stagingDir); + } + + if (!mkdir($stagingDir, 0755, true)) { + throw new \RuntimeException('Cannot create staging directory: ' . $stagingDir); + } + + $session->log('Extracting archive: ' . basename($archivePath)); + + // Detect format and extract + if (JpaUnarchiver::isJpaFile($archivePath)) { + $session->log('Detected JPA format (Akeeba Backup archive)'); + $jpa = new JpaUnarchiver($archivePath, $stagingDir); + $count = $jpa->extract(); + $session->log('Extracted ' . $count . ' files from JPA'); + } elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) { + $session->log('Detected tar.gz format'); + $phar = new \PharData($archivePath); + + // Validate entries for path traversal + foreach (new \RecursiveIteratorIterator($phar) as $entry) { + $entryName = $entry->getPathname(); + $relative = substr($entryName, strlen('phar://' . $archivePath) + 1); + + if (str_contains($relative, '../') || str_contains($relative, '..\\') + || str_starts_with($relative, '/') || str_starts_with($relative, '\\')) { + throw new \RuntimeException('Archive contains unsafe path: ' . $relative); + } + } + + $phar->extractTo($stagingDir, null, true); + $session->log('Extracted tar.gz archive'); + } else { + $this->extractZipArchive($archivePath, $stagingDir, $password, $session); + } + + $session->log('Extraction complete'); + + // Preserve configuration.php before any files are copied + if ($state['preserve_config'] && is_file(JPATH_ROOT . '/configuration.php')) { + $state['config_backup'] = file_get_contents(JPATH_ROOT . '/configuration.php'); + $session->log('Current configuration.php preserved'); + } + + // Build file list for the files phase + if ($state['restore_files']) { + $fileList = $this->scanStagingFiles($stagingDir); + $state['file_list'] = $fileList; + $state['file_index'] = 0; + + $fileBatches = (int) ceil(count($fileList) / self::FILE_BATCH_SIZE); + $session->log('Files to restore: ' . count($fileList) . ' (' . $fileBatches . ' batches)'); + } + + // Check for SQL file + $sqlFile = $stagingDir . '/database.sql'; + + if ($state['restore_db'] && is_file($sqlFile)) { + $state['sql_file'] = $sqlFile; + $state['sql_offset'] = 0; + $state['sql_done'] = false; + + // Estimate SQL batches by counting lines + $lineCount = 0; + $fh = fopen($sqlFile, 'r'); + + if ($fh) { + while (fgets($fh) !== false) { + $lineCount++; + } + + fclose($fh); + } + + // Rough estimate: each statement ~2 lines on average + $estimatedStatements = max(1, (int) ($lineCount / 2)); + $sqlBatches = (int) ceil($estimatedStatements / self::SQL_BATCH_SIZE); + $session->log('SQL file found: ~' . $estimatedStatements . ' statements (' . $sqlBatches . ' batches)'); + } elseif ($state['restore_db']) { + $session->log('No database.sql found in archive — skipping database restore'); + $state['restore_db'] = false; + } + + // Recalculate total steps now that we know the actual counts + $totalSteps = 1; // extract (done) + + if ($state['restore_files']) { + $totalSteps += max(1, (int) ceil(count($state['file_list']) / self::FILE_BATCH_SIZE)); + } + + if ($state['restore_db'] && !empty($state['sql_file'])) { + $totalSteps += max(1, $sqlBatches ?? 1); + } + + $totalSteps += 1; // config + $totalSteps += 1; // cleanup + + $session->totalSteps = $totalSteps; + $session->currentStep = 1; + + // Move to next phase + if ($state['restore_files']) { + $session->phase = 'files'; + } elseif ($state['restore_db'] && !empty($state['sql_file'])) { + $session->phase = 'database'; + } else { + $session->phase = 'config'; + } + + $session->statusMessage = 'Archive extracted — starting restore...'; + } + + /** + * Files phase: copy a batch of files from staging to JPATH_ROOT. + */ + private function stepFiles(SteppedSession $session, array &$state): void + { + $fileList = $state['file_list']; + $fileIndex = $state['file_index']; + $stagingDir = $state['staging_dir']; + $totalFiles = count($fileList); + + if ($fileIndex >= $totalFiles) { + // Files phase complete + $session->log('Files phase complete: ' . $totalFiles . ' files restored'); + + if ($state['restore_db'] && !empty($state['sql_file'])) { + $session->phase = 'database'; + } else { + $session->phase = 'config'; + } + + return; + } + + $batchEnd = min($fileIndex + self::FILE_BATCH_SIZE, $totalFiles); + $copied = 0; + $sourceBase = rtrim($stagingDir, '/\\'); + $targetBase = rtrim(JPATH_ROOT, '/\\'); + + // Files that should never be overwritten during restore + $skipFiles = ['configuration.php', 'configuration.php.bak', '.htaccess', 'web.config']; + $excludeFiles = ['database.sql']; + + for ($i = $fileIndex; $i < $batchEnd; $i++) { + $relativePath = $fileList[$i]; + $sourcePath = $sourceBase . '/' . $relativePath; + $targetPath = $targetBase . '/' . $relativePath; + $basename = basename($relativePath); + $dirPart = dirname($relativePath); + + // Skip excluded files + if (in_array($basename, $excludeFiles, true)) { + continue; + } + + // Skip protected files at root level + if (($dirPart === '' || $dirPart === '.') && in_array($basename, $skipFiles, true)) { + continue; + } + + if (!is_file($sourcePath)) { + continue; + } + + // Ensure parent directory exists + $parentDir = dirname($targetPath); + + if (!is_dir($parentDir)) { + mkdir($parentDir, 0755, true); + } + + if (copy($sourcePath, $targetPath)) { + $perms = fileperms($sourcePath); + + if ($perms !== false) { + @chmod($targetPath, $perms); + } + + $copied++; + } + } + + $state['file_index'] = $batchEnd; + + $session->currentStep++; + $batchNum = (int) ceil($batchEnd / self::FILE_BATCH_SIZE); + $totalBatch = (int) ceil($totalFiles / self::FILE_BATCH_SIZE); + $session->statusMessage = "Restoring files batch {$batchNum}/{$totalBatch} ({$copied} files copied)"; + $session->log("Files batch {$batchNum}: {$copied} files copied ({$batchEnd}/{$totalFiles})"); + + // Check if we're done with files + if ($batchEnd >= $totalFiles) { + $session->log('Files phase complete: ' . $totalFiles . ' files processed'); + + if ($state['restore_db'] && !empty($state['sql_file'])) { + $session->phase = 'database'; + } else { + $session->phase = 'config'; + } + } + } + + /** + * Database phase: import SQL statements in batches. + */ + private function stepDatabase(SteppedSession $session, array &$state): void + { + if ($state['sql_done'] || empty($state['sql_file'])) { + $session->log('Database phase complete: ' . $state['sql_executed'] . ' statements executed'); + $session->phase = 'config'; + + return; + } + + $sqlFile = $state['sql_file']; + $offset = $state['sql_offset']; + + $db = Factory::getDbo(); + $prefix = $db->getPrefix(); + + $handle = fopen($sqlFile, 'r'); + + if ($handle === false) { + throw new \RuntimeException('Cannot open SQL file: ' . $sqlFile); + } + + // Seek to the byte offset where we left off + if ($offset > 0) { + fseek($handle, $offset); + } + + $statementsExecuted = 0; + $currentStatement = ''; + $inMultiLineComment = false; + + while (($line = fgets($handle)) !== false) { + $trimmed = trim($line); + + // Skip empty lines + if ($trimmed === '') { + continue; + } + + // Skip single-line comments + if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) { + continue; + } + + // Handle multi-line comments + if (str_starts_with($trimmed, '/*')) { + $inMultiLineComment = true; + } + + if ($inMultiLineComment) { + if (str_contains($trimmed, '*/')) { + $inMultiLineComment = false; + } + + continue; + } + + // Accumulate the statement + $currentStatement .= $line; + + // Check if statement is complete (ends with semicolon) + if (str_ends_with($trimmed, ';')) { + $statement = trim($currentStatement); + $currentStatement = ''; + + if (empty($statement)) { + continue; + } + + // Replace abstract #__ prefix with the current site's prefix + $statement = str_replace('#__', $prefix, $statement); + + try { + $db->setQuery($statement); + $db->execute(); + } catch (\Exception $e) { + error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage()); + } + + $statementsExecuted++; + $state['sql_executed']++; + + // Check if we've hit the batch limit + if ($statementsExecuted >= self::SQL_BATCH_SIZE) { + $state['sql_offset'] = ftell($handle); + fclose($handle); + + $session->currentStep++; + $session->statusMessage = 'Importing database... (' . $state['sql_executed'] . ' statements executed)'; + $session->log('Database batch: ' . $statementsExecuted . ' statements (total: ' . $state['sql_executed'] . ')'); + + return; + } + } + } + + // Handle any remaining statement without trailing semicolon + $remaining = trim($currentStatement); + + if (!empty($remaining)) { + $remaining = str_replace('#__', $prefix, $remaining); + + try { + $db->setQuery($remaining); + $db->execute(); + $state['sql_executed']++; + } catch (\Exception $e) { + error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage()); + } + } + + fclose($handle); + + $state['sql_done'] = true; + $session->currentStep++; + $session->phase = 'config'; + $session->statusMessage = 'Database import complete: ' . $state['sql_executed'] . ' statements'; + $session->log('Database import complete: ' . $state['sql_executed'] . ' statements executed'); + } + + /** + * Config phase: restore preserved configuration.php. + */ + private function stepConfig(SteppedSession $session, array &$state): void + { + if ($state['preserve_config'] && !empty($state['config_backup'])) { + file_put_contents(JPATH_ROOT . '/configuration.php', $state['config_backup']); + $session->log('Configuration.php restored to pre-restore state'); + } + + $session->currentStep++; + $session->phase = 'cleanup'; + $session->statusMessage = 'Configuration restored — cleaning up...'; + } + + /** + * Cleanup phase: remove staging directory. + */ + private function stepCleanup(SteppedSession $session, array &$state): void + { + $stagingDir = $state['staging_dir']; + + if (!empty($stagingDir) && is_dir($stagingDir)) { + $this->recursiveDelete($stagingDir); + $session->log('Staging directory cleaned up'); + } + + $session->currentStep++; + $session->phase = 'complete'; + $session->statusMessage = 'Restore complete: ' . $session->archiveName; + $session->log('Restore complete'); + } + + /** + * Extract a ZIP archive to the staging directory with path traversal protection. + */ + private function extractZipArchive(string $archivePath, string $stagingDir, string $password, SteppedSession $session): void + { + $zip = new \ZipArchive(); + $result = $zip->open($archivePath); + + if ($result !== true) { + throw new \RuntimeException('Cannot open archive (error code: ' . $result . ')'); + } + + if (!empty($password)) { + $zip->setPassword($password); + $session->log('Decryption password set'); + } + + // Validate all entries before extraction (path traversal protection) + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = $zip->getNameIndex($i); + + if ($entryName === false) { + continue; + } + + if (str_contains($entryName, '../') || str_contains($entryName, '..\\') + || str_starts_with($entryName, '/') || str_starts_with($entryName, '\\')) { + $zip->close(); + throw new \RuntimeException('Archive contains unsafe path: ' . $entryName); + } + } + + if (!$zip->extractTo($stagingDir)) { + $zip->close(); + + throw new \RuntimeException( + 'Failed to extract archive. ' + . (!empty($password) ? 'Check that the decryption password is correct.' : 'The archive may be encrypted — provide a password.') + ); + } + + $session->log('Extracted ' . $zip->numFiles . ' entries'); + $zip->close(); + } + + /** + * Scan the staging directory and return a flat list of relative file paths. + */ + private function scanStagingFiles(string $stagingDir): array + { + $files = []; + $baseLen = strlen(rtrim($stagingDir, '/\\')) + 1; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($stagingDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isFile()) { + $relativePath = substr($item->getPathname(), $baseLen); + // Normalise directory separators + $relativePath = str_replace('\\', '/', $relativePath); + $files[] = $relativePath; + } + } + + return $files; + } + + /** + * Recursively delete a directory and all its contents. + */ + private function recursiveDelete(string $dir): void + { + if (!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); + } + + /** + * Save restore-specific state to a JSON file alongside the session. + */ + private function saveRestoreState(string $sessionId, array $state): void + { + $path = $this->getRestoreStatePath($sessionId); + + if (file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT)) === false) { + throw new \RuntimeException('Cannot save restore state: ' . $path); + } + } + + /** + * Load restore-specific state from disk. + */ + private function loadRestoreState(string $sessionId): ?array + { + $path = $this->getRestoreStatePath($sessionId); + + if (!is_file($path)) { + return null; + } + + $data = json_decode(file_get_contents($path), true); + + return is_array($data) ? $data : null; + } + + /** + * Delete restore state file. + */ + private function destroyRestoreState(string $sessionId): void + { + $path = $this->getRestoreStatePath($sessionId); + + if (is_file($path)) { + @unlink($path); + } + } + + /** + * Get the file path for restore-specific state. + */ + private function getRestoreStatePath(string $sessionId): string + { + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId); + $dir = JPATH_ROOT . '/tmp/mokosuitebackup-sessions'; + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new \RuntimeException('Cannot create session directory: ' . $dir); + } + } + + return $dir . '/' . $safe . '.restore.json'; + } +} diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index ce1213b..310bd60 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -346,6 +346,106 @@ $listDirn = $this->escape($this->state->get('list.direction')); } }); + // AJAX stepped restore + var restoreRunning = false; + + function showRestoreProgress() { + restoreRunning = true; + document.getElementById('mb-restore-modal').style.display = 'none'; + document.getElementById('mb-restore-progress-modal').style.display = 'block'; + } + + function hideRestoreProgress() { + restoreRunning = false; + document.getElementById('mb-restore-progress-modal').style.display = 'none'; + } + + function updateRestoreProgress(progress, message, phase) { + var bar = document.getElementById('mb-restore-progress-bar'); + bar.style.width = progress + '%'; + bar.textContent = progress + '%'; + document.getElementById('mb-restore-status').textContent = message; + document.getElementById('mb-restore-phase').textContent = 'Phase: ' + phase; + } + + window.addEventListener('beforeunload', function(e) { + if (restoreRunning) { + e.preventDefault(); + e.returnValue = ''; + } + }); + + async function startSteppedRestore(e) { + e.preventDefault(); + + var recordId = document.getElementById('mb-restore-record-id').value; + var restoreFiles = document.getElementById('mb-restore-files').checked ? 1 : 0; + var restoreDb = document.getElementById('mb-restore-db').checked ? 1 : 0; + var preserveConfig = document.getElementById('mb-restore-config').checked ? 1 : 0; + var password = document.getElementById('mb-restore-password').value; + + showRestoreProgress(); + updateRestoreProgress(0, 'Initializing restore...', 'init'); + + try { + var initResult = await postAjax({ + task: 'ajax.restoreInit', + id: recordId, + restore_files: restoreFiles, + restore_db: restoreDb, + preserve_config: preserveConfig, + encryption_password: password + }); + + if (initResult.error) { + updateRestoreProgress(0, 'ERROR: ' + initResult.message, 'failed'); + document.getElementById('mb-restore-title').textContent = 'Restore Failed'; + setTimeout(hideRestoreProgress, 5000); + return; + } + + var sessionId = initResult.session_id; + updateRestoreProgress(initResult.progress, initResult.message, initResult.phase); + + var done = false; + while (!done) { + var stepResult = await postAjax({ + task: 'ajax.restoreStep', + session_id: sessionId + }); + + if (stepResult.error) { + updateRestoreProgress(0, 'ERROR: ' + stepResult.message, 'failed'); + document.getElementById('mb-restore-title').textContent = 'Restore Failed'; + setTimeout(hideRestoreProgress, 5000); + return; + } + + updateRestoreProgress(stepResult.progress, stepResult.message, stepResult.phase); + done = stepResult.done || false; + } + + document.getElementById('mb-restore-title').textContent = 'Restore Complete'; + setTimeout(function() { + hideRestoreProgress(); + location.reload(); + }, 2000); + + } catch (err) { + updateRestoreProgress(0, 'ERROR: ' + err.message, 'failed'); + document.getElementById('mb-restore-title').textContent = 'Restore Failed'; + setTimeout(hideRestoreProgress, 5000); + } + } + + // Attach the AJAX restore handler to the restore form + document.addEventListener('DOMContentLoaded', function() { + var restoreForm = document.getElementById('mb-restore-form'); + if (restoreForm) { + restoreForm.addEventListener('submit', startSteppedRestore); + } + }); + // View Log modal handler document.addEventListener('click', function(e) { var btn = e.target.closest('.mb-view-log'); @@ -443,6 +543,18 @@ $listDirn = $this->escape($this->state->get('list.direction')); + +
+