* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * AJAX step-based backup 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. * * This overcomes max_execution_time restrictions on shared hosting * where ini_set() and set_time_limit() are disabled. */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; class SteppedBackupEngine { /** * Initialize a new stepped backup session. * * @return array{session_id: string, phase: string, progress: int, message: string} */ public function init(int $profileId, string $description = '', string $origin = 'backend'): array { // Run pre-flight checks before creating any backup record $preflight = new PreflightCheck(); $preflightResult = $preflight->run($profileId); if (!$preflightResult['pass']) { return [ 'error' => true, 'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']), 'warnings' => $preflightResult['warnings'], ]; } $db = Factory::getDbo(); // Load profile $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_profiles')) ->where($db->quoteName('id') . ' = ' . $profileId); $db->setQuery($query); $profile = $db->loadObject(); if (!$profile) { return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []]; } // Create session $session = SteppedSession::create(); $session->profileId = $profileId; $session->origin = $origin; $session->backupType = $profile->backup_type; // Parse profile settings $session->excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? ''); $session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? ''); $session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? ''); $session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER; $session->includeMokoRestore = $profile->include_mokorestore ?? '0'; $session->restoreScriptName = $profile->restore_script_name ?? 'restore.php'; $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); // Load multi-remote destinations from the remotes table $session->remoteDestinations = $this->loadRemoteDestinations($db, $profileId); $session->remoteIndex = 0; // Resolve placeholders in directory and filename $resolver = new PlaceholderResolver($profile); $backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir)); if (!BackupDirectory::ensureReady($backupDir)) { return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir]; } $now = date('Y-m-d H:i:s'); $tag = $resolver->getTag(); $archiveFormat = $profile->archive_format ?? 'zip'; $nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]'; // The stepped engine uses ZipArchive batch-by-batch, so only ZIP is // supported. For 7z / tar.gz the non-stepped BackupEngine must be used. if ($archiveFormat !== 'zip') { return [ 'error' => true, 'message' => 'The stepped backup engine only supports ZIP format. ' . 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.', ]; } $archiveName = $resolver->resolve($nameFormat) . '.zip'; $session->archivePath = $backupDir . '/' . $archiveName; $session->archiveName = $archiveName; $session->description = $description ?: ($profile->title . ' — ' . $now); // Create backup record $record = (object) [ 'profile_id' => $profileId, 'description' => $session->description, 'status' => 'running', 'origin' => $origin, 'backup_type' => $profile->backup_type, 'archivename' => $archiveName, 'absolute_path' => $session->archivePath, 'total_size' => 0, 'db_size' => 0, 'files_count' => 0, 'tables_count' => 0, 'multipart' => 0, 'tag' => $tag, 'backupstart' => $now, 'backupend' => '0000-00-00 00:00:00', 'filesexist' => 0, 'remote_filename' => '', 'log' => '', ]; $db->insertObject('#__mokosuitebackup_records', $record, 'id'); $session->recordId = $record->id; // Determine what work needs to be done and estimate steps $totalSteps = 1; // init step if ($profile->backup_type !== 'files') { // Count tables for database phase $tables = $this->getSiteTables($session->excludeTables); $session->tables = $tables; $totalSteps += count($tables); // one step per table } if ($profile->backup_type !== 'database') { // Scan files and split into batches $scanner = new FileScanner(JPATH_ROOT, $session->excludeDirs, $session->excludeFiles); $allFiles = $scanner->scan(); $session->filesCount = count($allFiles); $session->fileBatches = array_chunk($allFiles, $session->batchSize); $totalSteps += count($session->fileBatches); // one step per batch } $totalSteps += 1; // finalize step $remoteCount = count($session->remoteDestinations); $totalSteps += $remoteCount; $session->totalSteps = $totalSteps; $session->currentStep = 1; $session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files'; $session->log('Backup initialized: ' . $session->description); $session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ', remotes: ' . $remoteCount . ')'); // Log any preflight warnings into the session foreach ($preflightResult['warnings'] as $warning) { $session->log('PREFLIGHT WARNING: ' . $warning); } $session->statusMessage = 'Initialized — starting backup...'; $session->save(); return [ 'session_id' => $session->sessionId, 'phase' => $session->phase, 'progress' => $session->getProgress(), 'message' => $session->statusMessage, 'warnings' => $preflightResult['warnings'], ]; } /** * Run the next step of a backup 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]; } try { switch ($session->phase) { case 'database': $this->stepDatabase($session); break; case 'files': $this->stepFiles($session); break; case 'finalize': $this->stepFinalize($session); break; case 'upload': $this->stepUpload($session); break; case 'complete': $session->destroy(); return [ 'session_id' => $sessionId, 'phase' => 'complete', 'progress' => 100, 'message' => 'Backup complete: ' . $session->archiveName, 'done' => true, ]; } $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()); $this->failRecord($session, $e->getMessage()); $session->destroy(); return ['error' => true, 'message' => 'Backup failed: ' . $e->getMessage()]; } } /** * Database phase: dump one table per step. */ private function stepDatabase(SteppedSession $session): void { if ($session->tableIndex >= count($session->tables)) { // Database phase complete, move to files or finalize $session->phase = ($session->backupType !== 'database') ? 'files' : 'finalize'; $session->tablesCount = $session->tableIndex; $session->log('Database dump complete: ' . $session->tablesCount . ' tables'); return; } $table = $session->tables[$session->tableIndex]; $db = Factory::getDbo(); // Dump this single table $sql = $this->dumpSingleTable($db, $table); // Append to a temp SQL file that will be added to ZIP in finalize $sqlFile = $session->archivePath . '.sql'; $flags = $session->tableIndex === 0 ? 0 : FILE_APPEND; if ($session->tableIndex === 0) { $header = "-- MokoSuiteBackup Database Dump\n" . "-- Generated: " . date('Y-m-d H:i:s') . "\n" . "-- Prefix: " . $db->getPrefix() . "\n\n" . "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n" . "SET time_zone = \"+00:00\";\n\n"; if (file_put_contents($sqlFile, $header) === false) { throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile); } $flags = FILE_APPEND; } if (file_put_contents($sqlFile, $sql, $flags) === false) { throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile); } $session->dbSize += strlen($sql); $session->tableIndex++; $session->currentStep++; $session->statusMessage = 'Dumping table ' . $session->tableIndex . '/' . count($session->tables) . ': ' . $table; $session->log('Dumped table: ' . $table); } /** * Files phase: add one batch of files to ZIP per step. */ private function stepFiles(SteppedSession $session): void { if ($session->batchIndex >= count($session->fileBatches)) { $session->phase = 'finalize'; $session->log('Files phase complete: ' . $session->filesCount . ' files in ' . count($session->fileBatches) . ' batches'); return; } $batch = $session->fileBatches[$session->batchIndex]; $zip = new \ZipArchive(); $mode = $session->batchIndex === 0 ? (\ZipArchive::CREATE | \ZipArchive::OVERWRITE) : \ZipArchive::CREATE; if ($zip->open($session->archivePath, $mode) !== true) { throw new \RuntimeException('Cannot open archive for writing'); } $added = 0; foreach ($batch as $relativePath) { $fullPath = JPATH_ROOT . '/' . $relativePath; if (!is_file($fullPath) || !is_readable($fullPath)) { continue; } // Store config as .bak with credentials stripped — restore rebuilds it if (basename($relativePath) === 'configuration.php' && dirname($relativePath) === '.') { $sanitized = BackupEngine::sanitizeConfiguration($fullPath); $zip->addFromString('configuration.php.bak', $sanitized); } else { $zip->addFile($fullPath, $relativePath); } $added++; } $zip->close(); $session->batchIndex++; $session->currentStep++; $batchNum = $session->batchIndex; $totalBatches = count($session->fileBatches); $session->statusMessage = "Adding files batch {$batchNum}/{$totalBatches} ({$added} files)"; $session->log("Files batch {$batchNum}: {$added} files added"); } /** * Finalize phase: add database.sql to ZIP, apply MokoRestore wrapper. */ private function stepFinalize(SteppedSession $session): void { $zip = new \ZipArchive(); if ($zip->open($session->archivePath, \ZipArchive::CREATE) !== true) { throw new \RuntimeException('Cannot open archive for finalization'); } // Add database dump if it exists $sqlFile = $session->archivePath . '.sql'; if (is_file($sqlFile)) { $zip->addFile($sqlFile, 'database.sql'); } $zip->close(); // Clean up temp SQL file if (is_file($sqlFile) && !@unlink($sqlFile)) { error_log('MokoSuiteBackup: Could not delete temp SQL file: ' . $sqlFile); } $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; // Verify archive integrity $session->log('Verifying archive integrity...'); $this->verifyArchive($session->archivePath, $session->backupType); $session->log('Archive integrity verified'); // MokoRestore $mokoRestoreMode = $session->includeMokoRestore ?? '0'; $restoreScriptName = $session->restoreScriptName ?? 'restore.php'; if ($mokoRestoreMode === '1') { $session->log('Wrapping with MokoRestore script...'); $mokoRestorePath = $session->archivePath . '.mokorestore.zip'; MokoRestore::wrap($session->archivePath, $mokoRestorePath, $restoreScriptName); @unlink($session->archivePath); rename($mokoRestorePath, $session->archivePath); $totalSize = filesize($session->archivePath); $session->log('MokoRestore archive created'); } elseif ($mokoRestoreMode === 'standalone') { $restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName); $restoreDir = dirname($session->archivePath); $session->restoreScriptPath = $restoreDir . '/' . $restoreScriptName; try { MokoRestore::generateStandalone($session->restoreScriptPath); $session->log('Standalone ' . $restoreScriptName . ' generated'); } catch (\Throwable $e) { $session->log('MokoRestore error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); $session->log('Stack trace: ' . $e->getTraceAsString()); } } // Update record $db = Factory::getDbo(); $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; $update = (object) [ 'id' => $session->recordId, 'total_size' => $totalSize, 'db_size' => $session->dbSize, 'files_count' => $session->filesCount, 'tables_count' => $session->tablesCount, 'filesexist' => 1, ]; $db->updateObject('#__mokosuitebackup_records', $update, 'id'); $session->currentStep++; if (!empty($session->remoteDestinations)) { $session->phase = 'upload'; } else { $session->phase = 'complete'; } $session->statusMessage = 'Archive finalized: ' . $sizeHuman; $session->log('Archive finalized: ' . $sizeHuman); if ($session->phase === 'complete') { $this->completeRecord($session); } } /** * Upload phase: send archive to one remote destination per call. */ private function stepUpload(SteppedSession $session): void { $db = Factory::getDbo(); $remoteFilename = ''; $uploadFailed = false; $index = $session->remoteIndex; if ($index >= count($session->remoteDestinations)) { $session->phase = 'complete'; $session->statusMessage = 'All remote uploads finished'; $this->completeRecord($session); return; } $remote = (object) $session->remoteDestinations[$index]; try { $title = $remote->title ?? ('Remote #' . ($index + 1)); $type = $remote->type ?? 'unknown'; $params = json_decode($remote->params ?? '{}', true) ?: []; $session->log('Uploading to: ' . $title . ' (' . $type . ')...'); $uploader = $this->createUploaderFromParams($type, $params); $result = $uploader->upload($session->archivePath, $session->archiveName); if ($result['success']) { $remoteFilename = $result['remote_path'] ?? $session->archiveName; $session->log(' Upload complete: ' . $result['message']); if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) { $uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath)); } } else { $uploadFailed = true; $session->log(' WARNING: Upload failed: ' . $result['message']); } } catch (\Throwable $e) { $uploadFailed = true; $session->log(' WARNING: Upload exception: ' . $e->getMessage()); } $session->remoteIndex++; $session->currentStep++; $remaining = count($session->remoteDestinations) - $session->remoteIndex; $session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : ''); if ($session->remoteIndex >= count($session->remoteDestinations)) { if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) { @unlink($session->archivePath); $session->log('Local copy removed (remote_keep_local = off)'); } $update = (object) [ 'id' => $session->recordId, 'remote_filename' => $remoteFilename, 'filesexist' => is_file($session->archivePath) ? 1 : 0, ]; $db->updateObject('#__mokosuitebackup_records', $update, 'id'); $session->phase = 'complete'; $session->statusMessage = $uploadFailed ? 'Backup complete (some remote uploads failed — local archive preserved)' : 'Backup complete'; $this->completeRecord($session, $uploadFailed); } } /** * Verify that a backup archive can be opened and contains expected entries. * * @param string $archivePath Absolute path to the archive file * @param string $backupType Backup type: full, database, files, differential * * @throws \RuntimeException If the archive fails verification */ private function verifyArchive(string $archivePath, string $backupType): void { if (!is_file($archivePath)) { throw new \RuntimeException('Archive file does not exist: ' . $archivePath); } $zip = new \ZipArchive(); if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) { throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file'); } if ($zip->numFiles < 1) { $zip->close(); throw new \RuntimeException('Archive integrity check failed: archive contains no files'); } // Verify database.sql exists when backup includes database if ($backupType !== 'files') { if ($zip->locateName('database.sql') === false) { $zip->close(); throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive'); } } // Spot-check: verify the first entry is readable $firstName = $zip->getNameIndex(0); if ($firstName === false) { $zip->close(); throw new \RuntimeException('Archive integrity check failed: cannot read first entry'); } $zip->close(); } /** * Mark the backup record as complete. */ private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void { $db = Factory::getDbo(); $logContent = implode("\n", $session->log); // Write log file alongside the archive $logPath = BackupDirectory::logPathFromArchive($session->archivePath); if (@file_put_contents($logPath, $logContent) === false) { error_log('MokoSuiteBackup: Could not write log file: ' . $logPath); } $totalSize = is_file($session->archivePath) ? filesize($session->archivePath) : 0; $checksum = is_file($session->archivePath) ? hash_file('sha256', $session->archivePath) : ''; $update = (object) [ 'id' => $session->recordId, 'status' => $uploadFailed ? 'warning' : 'complete', 'backupend' => date('Y-m-d H:i:s'), 'total_size' => $totalSize, 'checksum' => $checksum, 'log' => $logContent, ]; $db->updateObject('#__mokosuitebackup_records', $update, 'id'); // Send notifications (email + ntfy) try { $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_profiles')) ->where($db->quoteName('id') . ' = ' . (int) $session->profileId); $db->setQuery($query); $profile = $db->loadObject(); if ($profile) { $record = (object) [ 'id' => $session->recordId, 'description' => $session->description ?? '', 'backup_type' => $session->backupType, 'archivename' => $session->archiveName, 'origin' => $session->origin, 'backupstart' => '', 'backupend' => date('Y-m-d H:i:s'), 'total_size' => $totalSize, 'files_count' => $session->filesCount ?? 0, 'tables_count' => $session->tablesCount ?? 0, 'remote_filename' => '', ]; NotificationSender::send($profile, $record, true, $logContent); // If remote upload failed, also send a failure notification for the upload if ($uploadFailed) { NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent); } // Enforce per-profile retention (age and/or copy count). $pruned = RetentionManager::prune($db, $profile); if ($pruned > 0) { $session->log('Retention: pruned ' . $pruned . ' old backup(s)'); } } } catch (\Throwable $e) { error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage()); } } /** * Mark the backup record as failed. */ private function failRecord(SteppedSession $session, string $error): void { $db = Factory::getDbo(); $logContent = implode("\n", $session->log); $update = (object) [ 'id' => $session->recordId, 'status' => 'fail', 'backupend' => date('Y-m-d H:i:s'), 'log' => $logContent, ]; $db->updateObject('#__mokosuitebackup_records', $update, 'id'); // Send failure notification try { $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_profiles')) ->where($db->quoteName('id') . ' = ' . (int) $session->profileId); $db->setQuery($query); $profile = $db->loadObject(); if ($profile) { $record = (object) [ 'id' => $session->recordId, 'description' => $session->description, 'backup_type' => $session->backupType, 'archivename' => $session->archiveName, 'origin' => $session->origin, 'backupstart' => '', 'backupend' => date('Y-m-d H:i:s'), 'total_size' => 0, 'files_count' => $session->filesCount, 'tables_count' => $session->tablesCount, 'remote_filename' => '', ]; NotificationSender::send($profile, $record, false, $logContent); } } catch (\Exception $e) { error_log('MokoSuiteBackup: SteppedBackupEngine failure notification failed: ' . $e->getMessage()); } } /** * Dump a single table to SQL string. */ private function dumpSingleTable(object $db, string $table): string { $prefix = $db->getPrefix(); $abstractName = '#__' . substr($table, strlen($prefix)); $output = []; $output[] = '-- --------------------------------------------------------'; $output[] = '-- Table: ' . $abstractName; $output[] = '-- --------------------------------------------------------'; $output[] = ''; // CREATE TABLE — replace live prefix with #__ $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); $createRow = $db->loadRow(); if (!$createRow || empty($createRow[1])) { return ''; } // Replace all occurrences of the live prefix — covers FK REFERENCES too $createSql = str_replace('`' . $prefix, '`#__', $createRow[1]); $output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;'; $output[] = $createSql . ';'; $output[] = ''; // Data in chunks $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); $rowCount = (int) $db->loadResult(); if ($rowCount === 0) { $output[] = '-- (empty table)'; $output[] = ''; return implode("\n", $output); } $chunkSize = 500; for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) { $db->setQuery( $db->getQuery(true)->select('*')->from($db->quoteName($table)), $offset, $chunkSize ); $rows = $db->loadAssocList(); if (empty($rows)) { break; } foreach ($rows as $row) { $values = []; foreach ($row as $value) { $values[] = $value === null ? 'NULL' : $db->quote($value); } $columns = array_map([$db, 'quoteName'], array_keys($row)); $output[] = 'INSERT INTO `' . $abstractName . '`' . ' (' . implode(', ', $columns) . ')' . ' VALUES (' . implode(', ', $values) . ');'; } } $output[] = ''; return implode("\n", $output); } /** * Get site tables (with prefix), excluding filtered tables. */ private function getSiteTables(array $excludeTables): array { $db = Factory::getDbo(); $prefix = $db->getPrefix(); $tables = []; foreach ($db->getTableList() as $table) { if (!str_starts_with($table, $prefix)) { continue; } $abstractName = '#__' . substr($table, strlen($prefix)); $excluded = false; foreach ($excludeTables as $pattern) { if ($pattern === $abstractName || $pattern === $table) { $excluded = true; break; } } if (!$excluded) { $tables[] = $table; } } return $tables; } /** * Load enabled remote destinations for a profile from the remotes table. * * Returns an empty array when the table does not exist (pre-migration) * so the caller can fall back to the legacy single-remote column. * * @param object $db Database driver * @param int $profileId Profile ID * * @return array Array of remote destination rows (as associative arrays for JSON serialization) */ private function loadRemoteDestinations(object $db, int $profileId): array { $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_remotes')) ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) ->where($db->quoteName('enabled') . ' = 1') ->order($db->quoteName('ordering') . ' ASC'); $db->setQuery($query); return $db->loadAssocList() ?: []; } /** * Create a remote uploader from JSON params (multi-remote destinations). * * Builds a fake profile-like object from the params array so the existing * uploader constructors work without modification. * * @param string $type Remote type: ftp, sftp, s3, google_drive * @param array $params Key-value params decoded from the remote's JSON * * @return RemoteUploaderInterface */ private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface { $prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_']; $prefix = $prefixMap[$type] ?? ''; $prefixed = []; foreach ($params as $key => $value) { $prefixed[$prefix . $key] = $value; } $fake = (object) $prefixed; return match ($type) { 'ftp' => new FtpUploader($fake), 'sftp' => new SftpUploader($fake), 'google_drive' => new GoogleDriveUploader($fake), 's3' => new S3Uploader($fake), default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), }; } }