diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4ec11..983d384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 2cdbbb7..bff38b2 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -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. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php index a5631fd..a214c7a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php +++ b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php @@ -236,6 +236,297 @@ class NotificationSender } } + /** + * Send a restore/snapshot notification via email and ntfy. + * + * @param object $profile Profile object with notification settings + * @param string $type One of: site_restore, snapshot_create, snapshot_restore + * @param array $details Context: record_id, content_types, row_count, mode, user, etc. + * @param string $log Operation log text + * + * @return bool True if at least one notification was sent + */ + public static function sendRestoreNotification(object $profile, string $type, array $details, string $log = ''): bool + { + $emailSent = self::sendRestoreEmail($profile, $type, $details, $log); + $ntfySent = self::sendRestoreNtfy($profile, $type, $details); + + return $emailSent || $ntfySent; + } + + /** + * Load the default profile (ID 1) for notification settings. + * + * @return object|null Profile object or null if not found + */ + public static function getDefaultProfile(): ?object + { + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = 1'); + $db->setQuery($query); + + return $db->loadObject() ?: null; + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Cannot load default profile: ' . $e->getMessage()); + + return null; + } + } + + /** + * Build subject and body for a restore/snapshot notification email. + */ + private static function buildRestoreMessage(string $type, array $details, string $siteName, string $siteUrl): array + { + $user = $details['user'] ?? 'Unknown'; + + switch ($type) { + case 'site_restore': + $subject = "[MokoSuiteBackup] RESTORE: Site restored — {$siteName}"; + $options = []; + + if (!empty($details['restore_files'])) { + $options[] = 'Files'; + } + + if (!empty($details['restore_db'])) { + $options[] = 'Database'; + } + + if (!empty($details['preserve_config'])) { + $options[] = 'Config preserved'; + } + + $body = "MokoSuiteBackup — Site Restore Notification\n" + . "=============================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Full site restore\n" + . "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" + . "Options: " . (empty($options) ? 'N/A' : implode(', ', $options)) . "\n" + . "Triggered by: {$user}\n"; + break; + + case 'snapshot_create': + $types = $details['content_types'] ?? []; + $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; + + $subject = "[MokoSuiteBackup] SNAPSHOT: Content snapshot created — {$siteName}"; + $body = "MokoSuiteBackup — Snapshot Created\n" + . "===================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Snapshot created\n" + . "Content types: {$typesStr}\n" + . "Articles: " . ($details['articles_count'] ?? 0) . "\n" + . "Categories: " . ($details['categories_count'] ?? 0) . "\n" + . "Modules: " . ($details['modules_count'] ?? 0) . "\n" + . "Triggered by: {$user}\n"; + break; + + case 'snapshot_restore': + $types = $details['content_types'] ?? []; + $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; + + $subject = "[MokoSuiteBackup] RESTORE: Snapshot restored — {$siteName}"; + $body = "MokoSuiteBackup — Snapshot Restore Notification\n" + . "================================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Snapshot restore\n" + . "Mode: " . ($details['mode'] ?? 'N/A') . "\n" + . "Content types: {$typesStr}\n" + . "Rows restored: " . ($details['row_count'] ?? 0) . "\n" + . "Triggered by: {$user}\n"; + break; + + default: + $subject = "[MokoSuiteBackup] NOTIFICATION: {$type} — {$siteName}"; + $body = "MokoSuiteBackup Notification\n" + . "============================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Type: {$type}\n" + . "Details: " . json_encode($details) . "\n"; + break; + } + + $body .= "\n--\n" + . "MokoSuiteBackup — https://mokoconsulting.tech\n"; + + return ['subject' => $subject, 'body' => $body]; + } + + /** + * Send a restore/snapshot notification email. + */ + private static function sendRestoreEmail(object $profile, string $type, array $details, string $log = ''): bool + { + $notifyEmail = trim($profile->notify_email ?? ''); + $notifyUserGroups = $profile->notify_user_groups ?? ''; + + $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); + + if (empty($notifyEmail) && empty($groupEmails)) { + return false; + } + + // Restore notifications are always "success" events — use notify_on_success preference + if (empty($profile->notify_on_success)) { + return false; + } + + try { + $mailer = Factory::getMailer(); + $config = Factory::getApplication()->getConfig(); + $siteName = $config->get('sitename', 'Joomla Site'); + $siteUrl = Uri::root(); + + $recipients = array_map('trim', explode(',', $notifyEmail)); + $recipients = array_merge($recipients, $groupEmails); + $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); + + if (empty($recipients)) { + return false; + } + + foreach ($recipients as $recipient) { + $mailer->addRecipient($recipient); + } + + $message = self::buildRestoreMessage($type, $details, $siteName, $siteUrl); + $mailer->setSubject($message['subject']); + + $body = $message['body']; + + // Append log excerpt if provided (last 30 lines) + if (!empty($log)) { + $logLines = explode("\n", $log); + $excerpt = array_slice($logLines, -30); + $body .= "\n--- Log (last 30 lines) ---\n" + . implode("\n", $excerpt) . "\n"; + } + + $mailer->setBody($body); + $mailer->isHtml(false); + + return $mailer->Send(); + } catch (\Throwable $e) { + error_log('MokoSuiteBackup restore notification error: ' . $e->getMessage()); + + return false; + } + } + + /** + * Send a restore/snapshot push notification via ntfy. + */ + private static function sendRestoreNtfy(object $profile, string $type, array $details): bool + { + $topic = trim($profile->ntfy_topic ?? ''); + $server = trim($profile->ntfy_server ?? 'https://ntfy.sh'); + $token = trim($profile->ntfy_token ?? ''); + + if ($topic === '') { + return false; + } + + // Restore notifications are always "success" events — use notify_on_success preference + if (empty($profile->notify_on_success)) { + return false; + } + + if (!function_exists('curl_init')) { + error_log('MokoSuiteBackup: ntfy notifications require ext-curl'); + + return false; + } + + try { + $config = Factory::getApplication()->getConfig(); + $siteName = $config->get('sitename', 'Joomla Site'); + + switch ($type) { + case 'site_restore': + $emoji = "\xF0\x9F\x94\x84"; // 🔄 + $title = "{$emoji} Site Restored: {$siteName}"; + $body = "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" + . "Triggered by: " . ($details['user'] ?? 'Unknown'); + break; + + case 'snapshot_create': + $emoji = "\xF0\x9F\x93\xB8"; // 📸 + $types = $details['content_types'] ?? []; + $title = "{$emoji} Snapshot Created: {$siteName}"; + $body = "Types: " . implode(', ', $types) . "\n" + . "Articles: " . ($details['articles_count'] ?? 0) . "\n" + . "Categories: " . ($details['categories_count'] ?? 0) . "\n" + . "Modules: " . ($details['modules_count'] ?? 0); + break; + + case 'snapshot_restore': + $emoji = "\xF0\x9F\x94\x84"; // 🔄 + $types = $details['content_types'] ?? []; + $title = "{$emoji} Snapshot Restored: {$siteName}"; + $body = "Mode: " . ($details['mode'] ?? 'N/A') . "\n" + . "Types: " . implode(', ', $types) . "\n" + . "Rows: " . ($details['row_count'] ?? 0); + break; + + default: + $title = "MokoSuiteBackup: {$type} — {$siteName}"; + $body = json_encode($details); + break; + } + + $url = rtrim($server, '/') . '/' . rawurlencode($topic); + + $headers = [ + 'Title: ' . $title, + 'Priority: 3', + 'Tags: arrows_counterclockwise', + ]; + + if ($token !== '') { + $headers[] = 'Authorization: Bearer ' . $token; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error !== '') { + error_log('MokoSuiteBackup: ntfy error: ' . $error); + return false; + } + + if ($httpCode < 200 || $httpCode >= 300) { + error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200)); + return false; + } + + return true; + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: ntfy restore notification error: ' . $e->getMessage()); + return false; + } + } + /** * Resolve user group IDs to email addresses of group members. * diff --git a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php index 7ffd2b6..29a3d0c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php @@ -146,6 +146,26 @@ class RestoreEngine $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), diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php index b581e63..cb63a2a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -194,6 +194,26 @@ class SnapshotEngine $this->log('Snapshot record created: ID ' . $record->id); + // Send snapshot creation notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; + + NotificationSender::sendRestoreNotification($profile, 'snapshot_create', [ + 'content_types' => array_values($validTypes), + 'articles_count' => $counts['articles'], + 'categories_count' => $counts['categories'], + 'modules_count' => $counts['modules'], + 'user' => $userName . ' (ID: ' . $userIdVal . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage()); + } + return [ 'success' => true, 'message' => sprintf( diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 917d276..329ecdb 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -151,6 +151,25 @@ class SnapshotRestoreEngine $this->log('Restore complete: ' . $totalRows . ' total rows'); + // Send snapshot restore notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; + + NotificationSender::sendRestoreNotification($profile, 'snapshot_restore', [ + 'mode' => $mode, + 'content_types' => $restoreTypes, + 'row_count' => $totalRows, + 'user' => $userName . ' (ID: ' . $userIdVal . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage()); + } + return [ 'success' => true, 'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)), diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php new file mode 100644 index 0000000..cf1b9ef --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php @@ -0,0 +1,753 @@ + + * @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'; + } +} diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index ce1213b..310bd60 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -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')); + + +