391047d8e5
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
293 lines
9.0 KiB
PHP
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;
|
|
}
|
|
}
|