* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * Restore engine — extracts a backup archive and reimports the database. * * Steps: * 1. Extract ZIP to a temp staging directory * 2. Preserve current configuration.php (DB credentials, paths) * 3. Restore files from staging to Joomla root * 4. Import database.sql (if present in archive) * 5. Restore preserved configuration.php * 6. Clean up staging directory */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; class RestoreEngine { private array $log = []; private string $stagingDir; /** * Run a full restore from a backup record. * * @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{success: bool, message: string} */ public function restore(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true, string $password = ''): array { // Override PHP limits — restores can take a long time @set_time_limit(0); @ini_set('max_execution_time', '0'); @ini_set('memory_limit', '512M'); @ignore_user_abort(true); if (!extension_loaded('zip')) { return ['success' => false, '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 ['success' => false, 'message' => 'Backup record not found: ' . $recordId]; } if ($record->status !== 'complete') { return ['success' => false, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')']; } $archivePath = $record->absolute_path; if (!is_file($archivePath) || !is_readable($archivePath)) { return ['success' => false, 'message' => 'Backup archive not found: ' . $archivePath]; } // Create staging directory (sanitize tag to prevent path traversal) $safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore'); $this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag; if (is_dir($this->stagingDir)) { $this->recursiveDelete($this->stagingDir); } mkdir($this->stagingDir, 0755, true); try { // Step 1: Extract archive to staging $this->log('Extracting archive: ' . basename($archivePath)); // Detect format: JPA, tar.gz, or ZIP if (JpaUnarchiver::isJpaFile($archivePath)) { $this->log('Detected JPA format (Akeeba Backup archive)'); $jpa = new JpaUnarchiver($archivePath, $this->stagingDir); $count = $jpa->extract(); $this->log('Extracted ' . $count . ' files from JPA'); } elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) { $this->log('Detected tar.gz format'); $this->extractTarGz($archivePath); } else { $this->extractArchive($archivePath, $password); } $this->log('Extraction complete'); // Step 2: Preserve configuration.php $configBackup = ''; if ($preserveConfig && is_file(JPATH_ROOT . '/configuration.php')) { $configBackup = file_get_contents(JPATH_ROOT . '/configuration.php'); $this->log('Current configuration.php preserved'); } // Step 3: Restore files if ($restoreFiles) { $this->log('Restoring files...'); $restorer = new FileRestorer($this->stagingDir, JPATH_ROOT); $fileCount = $restorer->restore(); $this->log('Files restored: ' . $fileCount); } // Step 4: Import database if ($restoreDb) { $sqlFile = $this->stagingDir . '/database.sql'; if (is_file($sqlFile)) { $this->log('Importing database...'); $importer = new DatabaseImporter(); $tableCount = $importer->import($sqlFile); $this->log('Database imported: ' . $tableCount . ' statements executed'); } else { $this->log('No database.sql found in archive — skipping database restore'); } } // Step 5: Restore preserved configuration.php if ($preserveConfig && !empty($configBackup)) { file_put_contents(JPATH_ROOT . '/configuration.php', $configBackup); $this->log('Configuration.php restored to pre-restore state'); } // Step 6: Clean up staging $this->recursiveDelete($this->stagingDir); $this->log('Staging directory cleaned up'); $this->log('Restore complete'); // Send restore notification try { $profile = NotificationSender::getDefaultProfile(); if ($profile) { $userId = Factory::getApplication()->getIdentity()->id ?? 0; $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; NotificationSender::sendRestoreNotification($profile, 'site_restore', [ 'record_id' => $recordId, 'restore_files' => $restoreFiles, 'restore_db' => $restoreDb, 'preserve_config' => $preserveConfig, 'user' => $userName . ' (ID: ' . $userId . ')', ], implode("\n", $this->log)); } } catch (\Throwable $e) { error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage()); } return [ 'success' => true, 'message' => 'Restore complete from: ' . basename($archivePath), 'log' => implode("\n", $this->log), ]; } catch (\Throwable $e) { $this->log('FATAL: ' . $e->getMessage()); // Restore config even on failure if ($preserveConfig && !empty($configBackup)) { file_put_contents(JPATH_ROOT . '/configuration.php', $configBackup); $this->log('Configuration.php restored after failure'); } // Clean up staging on failure if (is_dir($this->stagingDir)) { $this->recursiveDelete($this->stagingDir); } return [ 'success' => false, 'message' => 'Restore failed: ' . $e->getMessage(), 'log' => implode("\n", $this->log), ]; } } /** * Extract a ZIP archive to the staging directory. */ private function extractArchive(string $archivePath, string $password = ''): void { $zip = new \ZipArchive(); $result = $zip->open($archivePath); if ($result !== true) { throw new \RuntimeException('Cannot open archive (error code: ' . $result . ')'); } // Set decryption password if provided if (!empty($password)) { $zip->setPassword($password); $this->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($this->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.') ); } $this->log('Extracted ' . $zip->numFiles . ' entries'); $zip->close(); } /** * Extract a tar.gz archive to the staging directory. */ private function extractTarGz(string $archivePath): void { $phar = new \PharData($archivePath); // Validate all entries before extraction (path traversal protection) foreach (new \RecursiveIteratorIterator($phar) as $entry) { $entryName = $entry->getPathname(); // PharData paths are prefixed with phar:// — extract the relative part $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($this->stagingDir, null, true); $this->log('Extracted tar.gz archive'); } /** * 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); } private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; } }