feat: AJAX stepped restore engine for large sites (#62)
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: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 32s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
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: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 32s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Break restore into phases (extract, files, database, config, cleanup) executed via AJAX steps to avoid PHP timeout on shared hosting. - SteppedRestoreEngine with session persistence - AjaxController restoreInit/restoreStep endpoints - Restore modal uses AJAX progress instead of synchronous submit - Files copied in batches of 200, SQL in batches of 500 Closes #62
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- AJAX-based stepped restore engine for large sites — prevents timeout on shared hosting (#62)
|
||||
- Email/ntfy notifications for site restores and snapshot create/restore operations (#60)
|
||||
- Scheduled task type `mokosuitebackup.snapshot` for automated content snapshots via com_scheduler (#56)
|
||||
|
||||
## [01.31.00] --- 2026-06-22
|
||||
|
||||
## [01.31.00] --- 2026-06-22
|
||||
|
||||
@@ -18,6 +18,7 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class AjaxController extends BaseController
|
||||
@@ -308,6 +309,74 @@ class AjaxController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new stepped restore.
|
||||
* POST: task=ajax.restoreInit&id=123&restore_files=1&restore_db=1&preserve_config=1&encryption_password=
|
||||
*/
|
||||
public function restoreInit(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recordId = $this->input->getInt('id', 0);
|
||||
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
|
||||
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
|
||||
$preserveConfig = (bool) $this->input->getInt('preserve_config', 1);
|
||||
$password = $this->input->getString('encryption_password', '');
|
||||
|
||||
if (!$recordId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SteppedRestoreEngine();
|
||||
$result = $engine->init($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
||||
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the next step of a restore session.
|
||||
* POST: task=ajax.restoreStep&session_id=mb_...
|
||||
*/
|
||||
public function restoreStep(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionId = $this->input->getString('session_id', '');
|
||||
|
||||
if (empty($sessionId)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SteppedRestoreEngine();
|
||||
$result = $engine->runStep($sessionId);
|
||||
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
<?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
|
||||
*
|
||||
* AJAX step-based restore engine for shared hosting.
|
||||
*
|
||||
* Each call to runStep() performs one unit of work within the PHP time
|
||||
* limit, saves state, and returns. The browser JS fires the next step.
|
||||
*
|
||||
* Phases: extract -> files -> database -> config -> cleanup -> complete
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class SteppedRestoreEngine
|
||||
{
|
||||
/**
|
||||
* Number of files to copy per step during the files phase.
|
||||
*/
|
||||
private const FILE_BATCH_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Number of SQL statements to execute per step during the database phase.
|
||||
*/
|
||||
private const SQL_BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* Initialize a new stepped restore session.
|
||||
*
|
||||
* @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{session_id: string, phase: string, progress: int, message: string}
|
||||
*/
|
||||
public function init(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true, string $password = ''): array
|
||||
{
|
||||
if (!extension_loaded('zip')) {
|
||||
return ['error' => true, '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 ['error' => true, 'message' => 'Backup record not found: ' . $recordId];
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
|
||||
}
|
||||
|
||||
$archivePath = $record->absolute_path;
|
||||
|
||||
if (!is_file($archivePath) || !is_readable($archivePath)) {
|
||||
return ['error' => true, 'message' => 'Backup archive not found: ' . $archivePath];
|
||||
}
|
||||
|
||||
// Create session
|
||||
$session = SteppedSession::create();
|
||||
$session->recordId = $recordId;
|
||||
$session->archivePath = $archivePath;
|
||||
$session->archiveName = basename($archivePath);
|
||||
$session->description = 'Restore from: ' . ($record->description ?: basename($archivePath));
|
||||
|
||||
// Store restore-specific settings as dynamic properties via the session's
|
||||
// generic save/load (SteppedSession serialises all public properties).
|
||||
// We repurpose some existing fields and add restore-specific ones to the
|
||||
// session data stored on disk.
|
||||
$session->phase = 'extract';
|
||||
|
||||
// Build staging directory path
|
||||
$safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore');
|
||||
$stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag . '-' . substr($session->sessionId, 3);
|
||||
|
||||
// Estimate total steps
|
||||
$totalSteps = 1; // extract step
|
||||
|
||||
if ($restoreFiles) {
|
||||
$totalSteps += 1; // at least one files step (will adjust after extraction)
|
||||
}
|
||||
|
||||
if ($restoreDb) {
|
||||
$totalSteps += 1; // at least one database step (will adjust after extraction)
|
||||
}
|
||||
|
||||
$totalSteps += 1; // config step
|
||||
$totalSteps += 1; // cleanup step
|
||||
|
||||
$session->totalSteps = $totalSteps;
|
||||
$session->currentStep = 0;
|
||||
$session->statusMessage = 'Initializing restore...';
|
||||
|
||||
// Store restore-specific data in session log metadata
|
||||
// We'll use a JSON file alongside the session for restore state
|
||||
$restoreState = [
|
||||
'staging_dir' => $stagingDir,
|
||||
'restore_files' => $restoreFiles,
|
||||
'restore_db' => $restoreDb,
|
||||
'preserve_config' => $preserveConfig,
|
||||
'password' => $password,
|
||||
'config_backup' => '',
|
||||
'file_list' => [],
|
||||
'file_index' => 0,
|
||||
'sql_file' => '',
|
||||
'sql_offset' => 0,
|
||||
'sql_done' => false,
|
||||
'sql_executed' => 0,
|
||||
];
|
||||
|
||||
$this->saveRestoreState($session->sessionId, $restoreState);
|
||||
|
||||
$session->log('Restore initialized for record #' . $recordId . ': ' . $record->description);
|
||||
$session->log('Archive: ' . $archivePath);
|
||||
$session->log('Options: files=' . ($restoreFiles ? 'yes' : 'no')
|
||||
. ', database=' . ($restoreDb ? 'yes' : 'no')
|
||||
. ', preserve_config=' . ($preserveConfig ? 'yes' : 'no'));
|
||||
$session->save();
|
||||
|
||||
return [
|
||||
'session_id' => $session->sessionId,
|
||||
'phase' => $session->phase,
|
||||
'progress' => $session->getProgress(),
|
||||
'message' => $session->statusMessage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the next step of a restore session.
|
||||
*
|
||||
* @return array{session_id: string, phase: string, progress: int, message: string, done?: bool}
|
||||
*/
|
||||
public function runStep(string $sessionId): array
|
||||
{
|
||||
$session = SteppedSession::load($sessionId);
|
||||
|
||||
if (!$session) {
|
||||
return ['error' => true, 'message' => 'Session not found: ' . $sessionId];
|
||||
}
|
||||
|
||||
$restoreState = $this->loadRestoreState($sessionId);
|
||||
|
||||
if (!$restoreState) {
|
||||
return ['error' => true, 'message' => 'Restore state not found for session: ' . $sessionId];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($session->phase) {
|
||||
case 'extract':
|
||||
$this->stepExtract($session, $restoreState);
|
||||
break;
|
||||
|
||||
case 'files':
|
||||
$this->stepFiles($session, $restoreState);
|
||||
break;
|
||||
|
||||
case 'database':
|
||||
$this->stepDatabase($session, $restoreState);
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
$this->stepConfig($session, $restoreState);
|
||||
break;
|
||||
|
||||
case 'cleanup':
|
||||
$this->stepCleanup($session, $restoreState);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
$this->destroyRestoreState($sessionId);
|
||||
$session->destroy();
|
||||
|
||||
return [
|
||||
'session_id' => $sessionId,
|
||||
'phase' => 'complete',
|
||||
'progress' => 100,
|
||||
'message' => 'Restore complete: ' . $session->archiveName,
|
||||
'done' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$this->saveRestoreState($sessionId, $restoreState);
|
||||
$session->save();
|
||||
|
||||
return [
|
||||
'session_id' => $sessionId,
|
||||
'phase' => $session->phase,
|
||||
'progress' => $session->getProgress(),
|
||||
'message' => $session->statusMessage,
|
||||
'done' => $session->phase === 'complete',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$session->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Restore config on failure if we preserved it
|
||||
if (!empty($restoreState['config_backup']) && $restoreState['preserve_config']) {
|
||||
@file_put_contents(JPATH_ROOT . '/configuration.php', $restoreState['config_backup']);
|
||||
$session->log('Configuration.php restored after failure');
|
||||
}
|
||||
|
||||
// Clean up staging on failure
|
||||
$stagingDir = $restoreState['staging_dir'] ?? '';
|
||||
|
||||
if (!empty($stagingDir) && is_dir($stagingDir)) {
|
||||
$this->recursiveDelete($stagingDir);
|
||||
}
|
||||
|
||||
$this->destroyRestoreState($sessionId);
|
||||
$session->destroy();
|
||||
|
||||
return ['error' => true, 'message' => 'Restore failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract phase: extract archive to staging directory.
|
||||
*/
|
||||
private function stepExtract(SteppedSession $session, array &$state): void
|
||||
{
|
||||
$stagingDir = $state['staging_dir'];
|
||||
$archivePath = $session->archivePath;
|
||||
$password = $state['password'];
|
||||
|
||||
// Clean existing staging dir
|
||||
if (is_dir($stagingDir)) {
|
||||
$this->recursiveDelete($stagingDir);
|
||||
}
|
||||
|
||||
if (!mkdir($stagingDir, 0755, true)) {
|
||||
throw new \RuntimeException('Cannot create staging directory: ' . $stagingDir);
|
||||
}
|
||||
|
||||
$session->log('Extracting archive: ' . basename($archivePath));
|
||||
|
||||
// Detect format and extract
|
||||
if (JpaUnarchiver::isJpaFile($archivePath)) {
|
||||
$session->log('Detected JPA format (Akeeba Backup archive)');
|
||||
$jpa = new JpaUnarchiver($archivePath, $stagingDir);
|
||||
$count = $jpa->extract();
|
||||
$session->log('Extracted ' . $count . ' files from JPA');
|
||||
} elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
|
||||
$session->log('Detected tar.gz format');
|
||||
$phar = new \PharData($archivePath);
|
||||
|
||||
// Validate entries for path traversal
|
||||
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
|
||||
$entryName = $entry->getPathname();
|
||||
$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($stagingDir, null, true);
|
||||
$session->log('Extracted tar.gz archive');
|
||||
} else {
|
||||
$this->extractZipArchive($archivePath, $stagingDir, $password, $session);
|
||||
}
|
||||
|
||||
$session->log('Extraction complete');
|
||||
|
||||
// Preserve configuration.php before any files are copied
|
||||
if ($state['preserve_config'] && is_file(JPATH_ROOT . '/configuration.php')) {
|
||||
$state['config_backup'] = file_get_contents(JPATH_ROOT . '/configuration.php');
|
||||
$session->log('Current configuration.php preserved');
|
||||
}
|
||||
|
||||
// Build file list for the files phase
|
||||
if ($state['restore_files']) {
|
||||
$fileList = $this->scanStagingFiles($stagingDir);
|
||||
$state['file_list'] = $fileList;
|
||||
$state['file_index'] = 0;
|
||||
|
||||
$fileBatches = (int) ceil(count($fileList) / self::FILE_BATCH_SIZE);
|
||||
$session->log('Files to restore: ' . count($fileList) . ' (' . $fileBatches . ' batches)');
|
||||
}
|
||||
|
||||
// Check for SQL file
|
||||
$sqlFile = $stagingDir . '/database.sql';
|
||||
|
||||
if ($state['restore_db'] && is_file($sqlFile)) {
|
||||
$state['sql_file'] = $sqlFile;
|
||||
$state['sql_offset'] = 0;
|
||||
$state['sql_done'] = false;
|
||||
|
||||
// Estimate SQL batches by counting lines
|
||||
$lineCount = 0;
|
||||
$fh = fopen($sqlFile, 'r');
|
||||
|
||||
if ($fh) {
|
||||
while (fgets($fh) !== false) {
|
||||
$lineCount++;
|
||||
}
|
||||
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
// Rough estimate: each statement ~2 lines on average
|
||||
$estimatedStatements = max(1, (int) ($lineCount / 2));
|
||||
$sqlBatches = (int) ceil($estimatedStatements / self::SQL_BATCH_SIZE);
|
||||
$session->log('SQL file found: ~' . $estimatedStatements . ' statements (' . $sqlBatches . ' batches)');
|
||||
} elseif ($state['restore_db']) {
|
||||
$session->log('No database.sql found in archive — skipping database restore');
|
||||
$state['restore_db'] = false;
|
||||
}
|
||||
|
||||
// Recalculate total steps now that we know the actual counts
|
||||
$totalSteps = 1; // extract (done)
|
||||
|
||||
if ($state['restore_files']) {
|
||||
$totalSteps += max(1, (int) ceil(count($state['file_list']) / self::FILE_BATCH_SIZE));
|
||||
}
|
||||
|
||||
if ($state['restore_db'] && !empty($state['sql_file'])) {
|
||||
$totalSteps += max(1, $sqlBatches ?? 1);
|
||||
}
|
||||
|
||||
$totalSteps += 1; // config
|
||||
$totalSteps += 1; // cleanup
|
||||
|
||||
$session->totalSteps = $totalSteps;
|
||||
$session->currentStep = 1;
|
||||
|
||||
// Move to next phase
|
||||
if ($state['restore_files']) {
|
||||
$session->phase = 'files';
|
||||
} elseif ($state['restore_db'] && !empty($state['sql_file'])) {
|
||||
$session->phase = 'database';
|
||||
} else {
|
||||
$session->phase = 'config';
|
||||
}
|
||||
|
||||
$session->statusMessage = 'Archive extracted — starting restore...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Files phase: copy a batch of files from staging to JPATH_ROOT.
|
||||
*/
|
||||
private function stepFiles(SteppedSession $session, array &$state): void
|
||||
{
|
||||
$fileList = $state['file_list'];
|
||||
$fileIndex = $state['file_index'];
|
||||
$stagingDir = $state['staging_dir'];
|
||||
$totalFiles = count($fileList);
|
||||
|
||||
if ($fileIndex >= $totalFiles) {
|
||||
// Files phase complete
|
||||
$session->log('Files phase complete: ' . $totalFiles . ' files restored');
|
||||
|
||||
if ($state['restore_db'] && !empty($state['sql_file'])) {
|
||||
$session->phase = 'database';
|
||||
} else {
|
||||
$session->phase = 'config';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$batchEnd = min($fileIndex + self::FILE_BATCH_SIZE, $totalFiles);
|
||||
$copied = 0;
|
||||
$sourceBase = rtrim($stagingDir, '/\\');
|
||||
$targetBase = rtrim(JPATH_ROOT, '/\\');
|
||||
|
||||
// Files that should never be overwritten during restore
|
||||
$skipFiles = ['configuration.php', 'configuration.php.bak', '.htaccess', 'web.config'];
|
||||
$excludeFiles = ['database.sql'];
|
||||
|
||||
for ($i = $fileIndex; $i < $batchEnd; $i++) {
|
||||
$relativePath = $fileList[$i];
|
||||
$sourcePath = $sourceBase . '/' . $relativePath;
|
||||
$targetPath = $targetBase . '/' . $relativePath;
|
||||
$basename = basename($relativePath);
|
||||
$dirPart = dirname($relativePath);
|
||||
|
||||
// Skip excluded files
|
||||
if (in_array($basename, $excludeFiles, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip protected files at root level
|
||||
if (($dirPart === '' || $dirPart === '.') && in_array($basename, $skipFiles, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_file($sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
$parentDir = dirname($targetPath);
|
||||
|
||||
if (!is_dir($parentDir)) {
|
||||
mkdir($parentDir, 0755, true);
|
||||
}
|
||||
|
||||
if (copy($sourcePath, $targetPath)) {
|
||||
$perms = fileperms($sourcePath);
|
||||
|
||||
if ($perms !== false) {
|
||||
@chmod($targetPath, $perms);
|
||||
}
|
||||
|
||||
$copied++;
|
||||
}
|
||||
}
|
||||
|
||||
$state['file_index'] = $batchEnd;
|
||||
|
||||
$session->currentStep++;
|
||||
$batchNum = (int) ceil($batchEnd / self::FILE_BATCH_SIZE);
|
||||
$totalBatch = (int) ceil($totalFiles / self::FILE_BATCH_SIZE);
|
||||
$session->statusMessage = "Restoring files batch {$batchNum}/{$totalBatch} ({$copied} files copied)";
|
||||
$session->log("Files batch {$batchNum}: {$copied} files copied ({$batchEnd}/{$totalFiles})");
|
||||
|
||||
// Check if we're done with files
|
||||
if ($batchEnd >= $totalFiles) {
|
||||
$session->log('Files phase complete: ' . $totalFiles . ' files processed');
|
||||
|
||||
if ($state['restore_db'] && !empty($state['sql_file'])) {
|
||||
$session->phase = 'database';
|
||||
} else {
|
||||
$session->phase = 'config';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database phase: import SQL statements in batches.
|
||||
*/
|
||||
private function stepDatabase(SteppedSession $session, array &$state): void
|
||||
{
|
||||
if ($state['sql_done'] || empty($state['sql_file'])) {
|
||||
$session->log('Database phase complete: ' . $state['sql_executed'] . ' statements executed');
|
||||
$session->phase = 'config';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sqlFile = $state['sql_file'];
|
||||
$offset = $state['sql_offset'];
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
$handle = fopen($sqlFile, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Cannot open SQL file: ' . $sqlFile);
|
||||
}
|
||||
|
||||
// Seek to the byte offset where we left off
|
||||
if ($offset > 0) {
|
||||
fseek($handle, $offset);
|
||||
}
|
||||
|
||||
$statementsExecuted = 0;
|
||||
$currentStatement = '';
|
||||
$inMultiLineComment = false;
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
// Skip empty lines
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip single-line comments
|
||||
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle multi-line comments
|
||||
if (str_starts_with($trimmed, '/*')) {
|
||||
$inMultiLineComment = true;
|
||||
}
|
||||
|
||||
if ($inMultiLineComment) {
|
||||
if (str_contains($trimmed, '*/')) {
|
||||
$inMultiLineComment = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accumulate the statement
|
||||
$currentStatement .= $line;
|
||||
|
||||
// Check if statement is complete (ends with semicolon)
|
||||
if (str_ends_with($trimmed, ';')) {
|
||||
$statement = trim($currentStatement);
|
||||
$currentStatement = '';
|
||||
|
||||
if (empty($statement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace abstract #__ prefix with the current site's prefix
|
||||
$statement = str_replace('#__', $prefix, $statement);
|
||||
|
||||
try {
|
||||
$db->setQuery($statement);
|
||||
$db->execute();
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$statementsExecuted++;
|
||||
$state['sql_executed']++;
|
||||
|
||||
// Check if we've hit the batch limit
|
||||
if ($statementsExecuted >= self::SQL_BATCH_SIZE) {
|
||||
$state['sql_offset'] = ftell($handle);
|
||||
fclose($handle);
|
||||
|
||||
$session->currentStep++;
|
||||
$session->statusMessage = 'Importing database... (' . $state['sql_executed'] . ' statements executed)';
|
||||
$session->log('Database batch: ' . $statementsExecuted . ' statements (total: ' . $state['sql_executed'] . ')');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining statement without trailing semicolon
|
||||
$remaining = trim($currentStatement);
|
||||
|
||||
if (!empty($remaining)) {
|
||||
$remaining = str_replace('#__', $prefix, $remaining);
|
||||
|
||||
try {
|
||||
$db->setQuery($remaining);
|
||||
$db->execute();
|
||||
$state['sql_executed']++;
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
$state['sql_done'] = true;
|
||||
$session->currentStep++;
|
||||
$session->phase = 'config';
|
||||
$session->statusMessage = 'Database import complete: ' . $state['sql_executed'] . ' statements';
|
||||
$session->log('Database import complete: ' . $state['sql_executed'] . ' statements executed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Config phase: restore preserved configuration.php.
|
||||
*/
|
||||
private function stepConfig(SteppedSession $session, array &$state): void
|
||||
{
|
||||
if ($state['preserve_config'] && !empty($state['config_backup'])) {
|
||||
file_put_contents(JPATH_ROOT . '/configuration.php', $state['config_backup']);
|
||||
$session->log('Configuration.php restored to pre-restore state');
|
||||
}
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'cleanup';
|
||||
$session->statusMessage = 'Configuration restored — cleaning up...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup phase: remove staging directory.
|
||||
*/
|
||||
private function stepCleanup(SteppedSession $session, array &$state): void
|
||||
{
|
||||
$stagingDir = $state['staging_dir'];
|
||||
|
||||
if (!empty($stagingDir) && is_dir($stagingDir)) {
|
||||
$this->recursiveDelete($stagingDir);
|
||||
$session->log('Staging directory cleaned up');
|
||||
}
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = 'Restore complete: ' . $session->archiveName;
|
||||
$session->log('Restore complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a ZIP archive to the staging directory with path traversal protection.
|
||||
*/
|
||||
private function extractZipArchive(string $archivePath, string $stagingDir, string $password, SteppedSession $session): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
$result = $zip->open($archivePath);
|
||||
|
||||
if ($result !== true) {
|
||||
throw new \RuntimeException('Cannot open archive (error code: ' . $result . ')');
|
||||
}
|
||||
|
||||
if (!empty($password)) {
|
||||
$zip->setPassword($password);
|
||||
$session->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($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.')
|
||||
);
|
||||
}
|
||||
|
||||
$session->log('Extracted ' . $zip->numFiles . ' entries');
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the staging directory and return a flat list of relative file paths.
|
||||
*/
|
||||
private function scanStagingFiles(string $stagingDir): array
|
||||
{
|
||||
$files = [];
|
||||
$baseLen = strlen(rtrim($stagingDir, '/\\')) + 1;
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($stagingDir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
if ($item->isFile()) {
|
||||
$relativePath = substr($item->getPathname(), $baseLen);
|
||||
// Normalise directory separators
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
$files[] = $relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save restore-specific state to a JSON file alongside the session.
|
||||
*/
|
||||
private function saveRestoreState(string $sessionId, array $state): void
|
||||
{
|
||||
$path = $this->getRestoreStatePath($sessionId);
|
||||
|
||||
if (file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT)) === false) {
|
||||
throw new \RuntimeException('Cannot save restore state: ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load restore-specific state from disk.
|
||||
*/
|
||||
private function loadRestoreState(string $sessionId): ?array
|
||||
{
|
||||
$path = $this->getRestoreStatePath($sessionId);
|
||||
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete restore state file.
|
||||
*/
|
||||
private function destroyRestoreState(string $sessionId): void
|
||||
{
|
||||
$path = $this->getRestoreStatePath($sessionId);
|
||||
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for restore-specific state.
|
||||
*/
|
||||
private function getRestoreStatePath(string $sessionId): string
|
||||
{
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
|
||||
$dir = JPATH_ROOT . '/tmp/mokosuitebackup-sessions';
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0755, true)) {
|
||||
throw new \RuntimeException('Cannot create session directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
return $dir . '/' . $safe . '.restore.json';
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,106 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
}
|
||||
});
|
||||
|
||||
// AJAX stepped restore
|
||||
var restoreRunning = false;
|
||||
|
||||
function showRestoreProgress() {
|
||||
restoreRunning = true;
|
||||
document.getElementById('mb-restore-modal').style.display = 'none';
|
||||
document.getElementById('mb-restore-progress-modal').style.display = 'block';
|
||||
}
|
||||
|
||||
function hideRestoreProgress() {
|
||||
restoreRunning = false;
|
||||
document.getElementById('mb-restore-progress-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function updateRestoreProgress(progress, message, phase) {
|
||||
var bar = document.getElementById('mb-restore-progress-bar');
|
||||
bar.style.width = progress + '%';
|
||||
bar.textContent = progress + '%';
|
||||
document.getElementById('mb-restore-status').textContent = message;
|
||||
document.getElementById('mb-restore-phase').textContent = 'Phase: ' + phase;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (restoreRunning) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
async function startSteppedRestore(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var recordId = document.getElementById('mb-restore-record-id').value;
|
||||
var restoreFiles = document.getElementById('mb-restore-files').checked ? 1 : 0;
|
||||
var restoreDb = document.getElementById('mb-restore-db').checked ? 1 : 0;
|
||||
var preserveConfig = document.getElementById('mb-restore-config').checked ? 1 : 0;
|
||||
var password = document.getElementById('mb-restore-password').value;
|
||||
|
||||
showRestoreProgress();
|
||||
updateRestoreProgress(0, 'Initializing restore...', 'init');
|
||||
|
||||
try {
|
||||
var initResult = await postAjax({
|
||||
task: 'ajax.restoreInit',
|
||||
id: recordId,
|
||||
restore_files: restoreFiles,
|
||||
restore_db: restoreDb,
|
||||
preserve_config: preserveConfig,
|
||||
encryption_password: password
|
||||
});
|
||||
|
||||
if (initResult.error) {
|
||||
updateRestoreProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
|
||||
setTimeout(hideRestoreProgress, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionId = initResult.session_id;
|
||||
updateRestoreProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
var done = false;
|
||||
while (!done) {
|
||||
var stepResult = await postAjax({
|
||||
task: 'ajax.restoreStep',
|
||||
session_id: sessionId
|
||||
});
|
||||
|
||||
if (stepResult.error) {
|
||||
updateRestoreProgress(0, 'ERROR: ' + stepResult.message, 'failed');
|
||||
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
|
||||
setTimeout(hideRestoreProgress, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
updateRestoreProgress(stepResult.progress, stepResult.message, stepResult.phase);
|
||||
done = stepResult.done || false;
|
||||
}
|
||||
|
||||
document.getElementById('mb-restore-title').textContent = 'Restore Complete';
|
||||
setTimeout(function() {
|
||||
hideRestoreProgress();
|
||||
location.reload();
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
updateRestoreProgress(0, 'ERROR: ' + err.message, 'failed');
|
||||
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
|
||||
setTimeout(hideRestoreProgress, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the AJAX restore handler to the restore form
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var restoreForm = document.getElementById('mb-restore-form');
|
||||
if (restoreForm) {
|
||||
restoreForm.addEventListener('submit', startSteppedRestore);
|
||||
}
|
||||
});
|
||||
|
||||
// View Log modal handler
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.mb-view-log');
|
||||
@@ -443,6 +543,18 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Progress Modal -->
|
||||
<div id="mb-restore-progress-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h3 id="mb-restore-title" style="margin:0 0 1rem;">Restore in Progress</h3>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="mb-restore-progress-bar" style="height:100%; background:#dc3545; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||
</div>
|
||||
<p id="mb-restore-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
|
||||
<p id="mb-restore-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer Modal -->
|
||||
<div id="mb-log-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
|
||||
|
||||
Reference in New Issue
Block a user