Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php
T
Jonathan Miller 391047d8e5 feat: email/ntfy notifications for restore operations (#60)
Send notifications when site restores and snapshot create/restore
complete. Uses sendRestoreNotification() with type-specific subjects.
All calls wrapped in try-catch to never break the actual operation.

Closes #60
2026-06-22 09:27:16 -05:00

293 lines
9.0 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}
}