* @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\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\Component\MokoJoomBackup\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 { $db = Factory::getDbo(); // Load profile $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokojoombackup_profiles')) ->where($db->quoteName('id') . ' = ' . $profileId); $db->setQuery($query); $profile = $db->loadObject(); if (!$profile) { return ['error' => true, 'message' => 'Profile not found: ' . $profileId]; } // 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->remoteStorage = $profile->remote_storage ?? 'none'; $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); // 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(); $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; $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('#__mokojoombackup_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 $totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step $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) . ')'); $session->statusMessage = 'Initialized — starting backup...'; $session->save(); return [ 'session_id' => $session->sessionId, 'phase' => $session->phase, 'progress' => $session->getProgress(), 'message' => $session->statusMessage, ]; } /** * 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 $dumper = new DatabaseDumper([]); $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 = "-- MokoJoomBackup 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)) { $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('MokoJoomBackup: Could not delete temp SQL file: ' . $sqlFile); } $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; // MokoRestore wrapper if ($session->includeMokoRestore) { $session->log('Wrapping with MokoRestore script...'); $mokoRestorePath = $session->archivePath . '.mokorestore.zip'; MokoRestore::wrap($session->archivePath, $mokoRestorePath); @unlink($session->archivePath); rename($mokoRestorePath, $session->archivePath); $totalSize = filesize($session->archivePath); $session->log('MokoRestore archive created'); } // 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('#__mokojoombackup_records', $update, 'id'); $session->currentStep++; $session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete'; $session->statusMessage = 'Archive finalized: ' . $sizeHuman; $session->log('Archive finalized: ' . $sizeHuman); if ($session->phase === 'complete') { $this->completeRecord($session); } } /** * Upload phase: send archive to remote storage. */ private function stepUpload(SteppedSession $session): void { $db = Factory::getDbo(); // Reload profile for remote settings $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokojoombackup_profiles')) ->where($db->quoteName('id') . ' = ' . $session->profileId); $db->setQuery($query); $profile = $db->loadObject(); $uploader = match ($session->remoteStorage) { 'ftp' => new FtpUploader($profile), 'google_drive' => new GoogleDriveUploader($profile), 's3' => new S3Uploader($profile), default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage), }; $session->log('Starting remote upload (' . $session->remoteStorage . ')...'); $result = $uploader->upload($session->archivePath, $session->archiveName); $remoteFilename = ''; if ($result['success']) { $remoteFilename = $result['remote_path'] ?? $session->archiveName; $session->log('Remote upload complete: ' . $result['message']); if (!$session->remoteKeepLocal && is_file($session->archivePath)) { @unlink($session->archivePath); $session->log('Local copy removed'); } } else { $session->log('WARNING: Remote upload failed: ' . $result['message']); } // Update record with remote filename $update = (object) [ 'id' => $session->recordId, 'remote_filename' => $remoteFilename, 'filesexist' => is_file($session->archivePath) ? 1 : 0, ]; $db->updateObject('#__mokojoombackup_records', $update, 'id'); $session->currentStep++; $session->phase = 'complete'; $session->statusMessage = 'Backup complete'; $this->completeRecord($session); } /** * Mark the backup record as complete. */ private function completeRecord(SteppedSession $session): 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('MokoJoomBackup: Could not write log file: ' . $logPath); } $update = (object) [ 'id' => $session->recordId, 'status' => 'complete', 'backupend' => date('Y-m-d H:i:s'), 'log' => $logContent, ]; $db->updateObject('#__mokojoombackup_records', $update, 'id'); } /** * Mark the backup record as failed. */ private function failRecord(SteppedSession $session, string $error): void { $db = Factory::getDbo(); $update = (object) [ 'id' => $session->recordId, 'status' => 'fail', 'backupend' => date('Y-m-d H:i:s'), 'log' => implode("\n", $session->log), ]; $db->updateObject('#__mokojoombackup_records', $update, 'id'); } /** * Dump a single table to SQL string. */ private function dumpSingleTable(object $db, string $table): string { $output = []; $output[] = '-- --------------------------------------------------------'; $output[] = '-- Table: ' . $table; $output[] = '-- --------------------------------------------------------'; $output[] = ''; // CREATE TABLE $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); $createRow = $db->loadRow(); if (!$createRow || empty($createRow[1])) { return ''; } $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; $output[] = $createRow[1] . ';'; $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 ' . $db->quoteName($table) . ' (' . 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; } }