* @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'; } }