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
#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
273 lines
8.3 KiB
PHP
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;
|
|
}
|
|
}
|