* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; use Joomla\Event\Event; class BackupEngine { private string $backupDir; private array $log = []; /** * Run a backup using the specified profile. * * @param int $profileId Profile ID to use * @param string $description Human-readable description * @param string $origin Origin: backend, cli, api, scheduled * * @return array{success: bool, message: string, record_id?: int} */ public function run(int $profileId, string $description, string $origin = 'backend'): array { // Override PHP limits for long-running backup operations $this->overridePhpLimits(); // Verify required extensions $extCheck = $this->checkRequiredExtensions(); if ($extCheck !== true) { return ['success' => false, 'message' => $extCheck]; } $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 ['success' => false, 'message' => 'Profile not found: ' . $profileId]; } // Read settings directly from profile columns $excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? ''); $excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? ''); $excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? ''); // Resolve placeholders in directory and filename $resolver = new PlaceholderResolver($profile); $configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER; $this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir)); if (!BackupDirectory::ensureReady($this->backupDir)) { return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0]; } // Create backup record $now = date('Y-m-d H:i:s'); $tag = $resolver->getTag(); $archiveFormat = $profile->archive_format ?? 'zip'; $archiver = $this->createArchiver($archiveFormat); $archiveExt = $archiver->getExtension(); $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; $archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt; if (empty($description)) { $description = $profile->title . ' — ' . $now; } $record = (object) [ 'profile_id' => $profileId, 'description' => $description, 'status' => 'running', 'origin' => $origin, 'backup_type' => $profile->backup_type, 'archivename' => $archiveName, 'absolute_path' => $this->backupDir . '/' . $archiveName, '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'); $recordId = $record->id; try { $this->log('Backup started: ' . $description); $archivePath = $this->backupDir . '/' . $archiveName; // Create archive $archiver->open($archivePath); $dbSize = 0; $filesCount = 0; $tablesCount = 0; // Step 1: Database dump (unless files-only) if ($profile->backup_type !== 'files') { $this->log('Starting database dump...'); $dumper = new DatabaseDumper($excludeTables); $sqlDump = $dumper->dump(); $archiver->addFromString('database.sql', $sqlDump); $dbSize = strlen($sqlDump); $tablesCount = $dumper->getTablesCount(); $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); } // Step 2: Files (unless database-only) $manifest = []; if ($profile->backup_type !== 'database') { $this->log('Starting file scan...'); $scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles); $allFiles = $scanner->scan(); // Differential: only include changed files if ($profile->backup_type === 'differential') { $baseManifest = $this->loadBaseManifest($db, $profileId); if (empty($baseManifest)) { $this->log('No base full backup found — running full backup instead'); $filesToBackup = $allFiles; } else { $filesToBackup = DifferentialScanner::getChangedFiles($allFiles, $baseManifest, JPATH_ROOT); $this->log('Differential: ' . count($filesToBackup) . ' changed files out of ' . count($allFiles) . ' total'); } } else { $filesToBackup = $allFiles; } $filesCount = count($filesToBackup); $this->log('Backing up ' . $filesCount . ' files'); $skippedFiles = 0; foreach ($filesToBackup as $relativePath) { $fullPath = JPATH_ROOT . '/' . $relativePath; if (!is_file($fullPath) || !is_readable($fullPath)) { $skippedFiles++; continue; } // Store configuration.php as .bak with credentials stripped. // The restore process rebuilds a fresh configuration.php // from user input + non-sensitive values from the .bak. if ($relativePath === 'configuration.php') { $sanitized = self::sanitizeConfiguration($fullPath); $archiver->addFromString('configuration.php.bak', $sanitized); $this->log('configuration.php saved as .bak (credentials stripped)'); } else { $archiver->addFile($fullPath, $relativePath); } } if ($skippedFiles > 0) { $this->log('WARNING: ' . $skippedFiles . ' files skipped (not readable or missing)'); } $this->log('Files added to archive'); // Build manifest for full/differential backups (used by future differentials) if ($profile->backup_type === 'full' || ($profile->backup_type === 'differential' && empty($baseManifest))) { $manifest = DifferentialScanner::buildManifest($allFiles, JPATH_ROOT); $this->log('File manifest built: ' . count($manifest) . ' entries'); } } $archiver->close(); // Step 1.5: Apply AES-256 encryption (if configured) $encryptionPassword = $profile->encryption_password ?? ''; if (!empty($encryptionPassword)) { if ($archiveFormat !== 'zip') { $this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption'); } else { $this->log('Encrypting archive with AES-256...'); $this->encryptArchive($archivePath, $encryptionPassword); $this->log('Archive encrypted'); } } // Record archive size and compute checksum (after encryption) $totalSize = file_exists($archivePath) ? filesize($archivePath) : 0; $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; $checksum = is_file($archivePath) ? hash_file('sha256', $archivePath) : ''; $this->log('Archive created: ' . $sizeHuman); $this->log('SHA-256: ' . ($checksum ?: 'N/A')); // Step 2.5: Wrap with MokoRestore script (if enabled) $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); if ($includeMokoRestore) { $this->log('Wrapping with MokoRestore script...'); $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName); $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName; MokoRestore::wrap($archivePath, $mokoRestorePath); // Replace the original archive with the wrapped one if (is_file($archivePath) && !unlink($archivePath)) { $this->log('WARNING: Could not remove pre-wrap archive'); } rename($mokoRestorePath, $archivePath); $totalSize = filesize($archivePath); $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; // Recompute checksum for the final wrapped archive $checksum = hash_file('sha256', $archivePath); $this->log('MokoRestore archive created: ' . $sizeHuman); $this->log('SHA-256 (wrapped): ' . $checksum); } $remoteFilename = ''; // Step 3: Remote upload (if configured) $remoteStorage = $profile->remote_storage ?? 'none'; if ($remoteStorage !== 'none') { $this->log('Starting remote upload (' . $remoteStorage . ')...'); $uploader = $this->createUploader($remoteStorage, $profile); $uploadResult = $uploader->upload($archivePath, $archiveName); if ($uploadResult['success']) { $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; $this->log('Remote upload complete: ' . $uploadResult['message']); // Delete local copy if configured if (empty($profile->remote_keep_local) && is_file($archivePath)) { @unlink($archivePath); $this->log('Local copy removed (remote_keep_local = off)'); } } else { $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']); $this->log('Local backup is preserved.'); } } // Write log file alongside the archive $logContent = implode("\n", $this->log); $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath); if (@file_put_contents($logPath, $logContent) === false) { error_log('MokoSuiteBackup: Could not write log file: ' . $logPath); } // Final record update (includes fields needed by NotificationSender) $update = (object) [ 'id' => $recordId, 'status' => 'complete', 'description' => $description, 'backup_type' => $profile->backup_type, 'archivename' => $archiveName, 'origin' => $origin, 'backupstart' => $now, 'total_size' => $totalSize, 'db_size' => $dbSize, 'files_count' => $filesCount, 'tables_count' => $tablesCount, 'backupend' => date('Y-m-d H:i:s'), 'filesexist' => is_file($archivePath) ? 1 : 0, 'remote_filename' => $remoteFilename, 'checksum' => $checksum, 'manifest' => !empty($manifest) ? json_encode($manifest) : '', 'log' => $logContent, ]; $db->updateObject('#__mokosuitebackup_records', $update, 'id'); // Send success notification NotificationSender::send($profile, $update, true, implode("\n", $this->log)); // Dispatch event for actionlog and other listeners $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin); return [ 'success' => true, 'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')', 'record_id' => $recordId, ]; } catch (\Throwable $e) { $this->log('FATAL: ' . $e->getMessage()); $update = (object) [ 'id' => $recordId, 'status' => 'fail', 'description' => $description ?: '', 'backup_type' => $profile->backup_type ?? 'full', 'origin' => $origin, 'archivename' => $archiveName, 'backupstart' => $now ?? date('Y-m-d H:i:s'), 'backupend' => date('Y-m-d H:i:s'), 'total_size' => 0, 'files_count' => 0, 'tables_count' => 0, 'remote_filename' => '', 'log' => implode("\n", $this->log), ]; $db->updateObject('#__mokosuitebackup_records', $update, 'id'); // Send failure notification NotificationSender::send($profile, $update, false, implode("\n", $this->log)); // Dispatch event for actionlog and other listeners $this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin); return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId]; } } /** * Override PHP execution limits for backup operations. * Attempts multiple methods since some hosts restrict ini_set. */ private function overridePhpLimits(): void { // Remove execution time limit (0 = unlimited) @set_time_limit(0); @ini_set('max_execution_time', '0'); // Increase memory limit for large sites $currentMemory = $this->parseBytes(ini_get('memory_limit')); if ($currentMemory > 0 && $currentMemory < 512 * 1024 * 1024) { @ini_set('memory_limit', '512M'); } // Disable output buffering to prevent memory buildup while (@ob_end_clean()) { // flush all output buffers } // Prevent browser/proxy timeout by disabling compression @ini_set('zlib.output_compression', 'Off'); // Ignore user abort so backup completes even if browser closes @ignore_user_abort(true); } /** * Parse a PHP ini byte value (e.g. "128M") into bytes. */ private function parseBytes(string $value): int { $value = trim($value); if ($value === '-1' || $value === '') { return -1; } $unit = strtolower(substr($value, -1)); $num = (int) $value; return match ($unit) { 'g' => $num * 1024 * 1024 * 1024, 'm' => $num * 1024 * 1024, 'k' => $num * 1024, default => $num, }; } /** * Verify required PHP extensions are loaded. * * @return true|string True if all ok, or error message string */ private function checkRequiredExtensions(): true|string { $required = [ 'zip' => 'ext-zip (required for archive creation)', 'pdo' => 'ext-pdo (required for database operations)', 'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)', 'mbstring' => 'ext-mbstring (required for binary-safe operations)', ]; $missing = []; foreach ($required as $ext => $label) { if (!extension_loaded($ext)) { $missing[] = $label; } } if (!empty($missing)) { return 'Missing PHP extensions: ' . implode(', ', $missing); } return true; } /** * Create the appropriate archiver based on the archive format. */ private function createArchiver(string $format): ArchiverInterface { return match ($format) { 'zip' => new ZipArchiver(), 'tar.gz' => new TarGzArchiver(), default => new ZipArchiver(), }; } /** * Create the appropriate remote uploader based on the storage type. */ private function createUploader(string $type, object $profile): RemoteUploaderInterface { return match ($type) { 'ftp' => new FtpUploader($profile), 'google_drive' => new GoogleDriveUploader($profile), 's3' => new S3Uploader($profile), default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), }; } /** * Load the file manifest from the most recent full backup for this profile. * Used by differential backups to determine which files changed. */ private function loadBaseManifest(object $db, int $profileId): array { $query = $db->getQuery(true) ->select($db->quoteName('manifest')) ->from($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('profile_id') . ' = ' . $profileId) ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) ->where($db->quoteName('manifest') . ' != ' . $db->quote('')) ->where($db->quoteName('backup_type') . ' = ' . $db->quote('full')) ->order($db->quoteName('backupstart') . ' DESC'); $db->setQuery($query, 0, 1); $manifestJson = $db->loadResult(); if (empty($manifestJson)) { return []; } return json_decode($manifestJson, true) ?: []; } /** * Encrypt a ZIP archive using AES-256. * * Uses ZipArchive::setEncryptionName() (PHP 7.2+) which produces * WinZip-compatible AES-256 encrypted archives. Falls back to * re-creating the archive with per-file encryption if needed. */ private function encryptArchive(string $archivePath, string $password): void { if (!defined('ZipArchive::EM_AES_256')) { throw new \RuntimeException( 'AES-256 ZIP encryption requires PHP 7.2+ compiled with libzip 1.2.0+. ' . 'Your PHP installation does not support ZipArchive::EM_AES_256.' ); } $zip = new \ZipArchive(); if ($zip->open($archivePath) !== true) { throw new \RuntimeException('Cannot open archive for encryption'); } $zip->setPassword($password); $numFiles = $zip->numFiles; for ($i = 0; $i < $numFiles; $i++) { $name = $zip->getNameIndex($i); if ($name === false) { $this->log('WARNING: Could not read file at index ' . $i . ' during encryption — file may remain unencrypted'); continue; } $zip->setEncryptionName($name, \ZipArchive::EM_AES_256); } $zip->close(); } /** * Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react. */ private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void { try { $app = Factory::getApplication(); $event = new Event('onMokoSuiteBackupAfterRun', [ 'success' => $success, 'record_id' => $recordId, 'description' => $description, 'profile_id' => $profileId, 'origin' => $origin, ]); $app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRun', $event); } catch (\Throwable $e) { // Never let a listener failure break the backup result, but log it error_log('MokoSuiteBackup: onAfterRun listener error: ' . $e->getMessage()); } } /** * Sanitize configuration.php by replacing sensitive field values with * [SANITIZED:fieldname] placeholders. Non-sensitive fields (sitename, * debug, cache, SEF, etc.) are preserved as-is. * * @param string $path Absolute path to configuration.php * * @return string Sanitized file contents */ public static function sanitizeConfiguration(string $path): string { $content = file_get_contents($path); if ($content === false) { error_log('MokoSuiteBackup: sanitizeConfiguration() failed to read: ' . $path); return ''; } // Fields whose values must be replaced with placeholders. // Grouped by category for maintainability. $sensitiveFields = [ // Database 'host', 'user', 'password', 'db', // Security 'secret', // SMTP 'smtpuser', 'smtppass', 'smtphost', // Proxy 'proxy_user', 'proxy_pass', // Redis 'redis_server_auth', 'session_redis_server_auth', // Database TLS 'dbsslkey', 'dbsslcert', 'dbsslca', ]; foreach ($sensitiveFields as $field) { // Match: public $field = 'value'; (single-quoted) $content = preg_replace( '/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*\').*?(\';)/m', '$1[SANITIZED:' . $field . ']$2', $content ); // Match: public $field = "value"; (double-quoted) $content = preg_replace( '/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*").*?("\s*;)/m', '$1[SANITIZED:' . $field . ']$2', $content ); } return $content; } private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; } }