Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php
T
Jonathan Miller 3328d7cf19
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 25s
Universal: Build & Release / Promote to RC (pull_request) Successful in 28s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
feat: backup type filter + path traversal protection (#68, #72)
#68: Add backup type filter dropdown to backups list view
- filter_backups.xml: full/database/files/differential options
- BackupsModel: backup_type filter in getListQuery()
- Language string: COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL

#72: Path traversal protection in RestoreEngine and MokoRestore
- RestoreEngine::extractArchive(): validate ZIP entries before extractTo()
- RestoreEngine::extractTarGz(): validate PharData entries before extractTo()
- MokoRestore standalone script: same validation in generated PHP code
- Rejects entries containing ../ or starting with / or \

Closes #68, closes #72
2026-06-21 18:50:07 -05:00

273 lines
8.3 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');
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;
}
}