* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * Standalone restore/installer script generator. * * When "Include MokoRestore" is enabled on a profile, the backup archive * is wrapped: * * outer.zip * ├── restore.php ← Standalone installer (no Joomla needed) * └── site-backup.zip ← The actual site backup * * Upload outer.zip to a blank server, extract, open restore.php in a * browser, and it handles everything — self-contained site restoration * with a Joomla-styled wizard interface. */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; class MokoRestore { /** * Wrap a backup archive with the standalone restore script. * * @param string $backupArchive Path to the original backup ZIP * @param string $outputPath Path for the wrapped archive * * @return string Path to the wrapped archive */ public static function wrap(string $backupArchive, string $outputPath): string { $zip = new \ZipArchive(); if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath); } // Add the standalone restore script $zip->addFromString('restore.php', self::generateRestoreScript()); // Add the original backup as a nested ZIP $zip->addFile($backupArchive, 'site-backup.zip'); $zip->close(); return $outputPath; } /** * Generate the standalone restore.php script as a separate file. * * Unlike the wrapped version, this script scans its own directory * for ZIP files and lets the user choose which one to restore from. * * @param string $outputPath Where to write restore.php * * @return string Path to the generated script */ public static function generateStandalone(string $outputPath): string { $script = self::generateStandaloneScript(); if (file_put_contents($outputPath, $script) === false) { throw new \RuntimeException('Cannot write standalone restore script: ' . $outputPath); } return $outputPath; } /** * Generate the standalone script content that scans for ZIPs. */ private static function generateStandaloneScript(): string { /* Take the normal backend but replace the hardcoded BACKUP_FILE with a directory scanner that finds ZIP files */ $php = self::generateBackend(); /* Replace the fixed BACKUP_FILE constant with dynamic scanner */ $php = str_replace( "define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');", "/* BACKUP_FILE is set dynamically — see actionSelectBackup() below */\n" . "define('BACKUP_FILE', ''); /* placeholder — overridden per request */", $php ); /* Inject the backup scanner function after the constants */ $scannerCode = <<<'SCANNER' /** * Scan the restore directory for ZIP files that look like backups. */ function scanForBackups(): array { $dir = RESTORE_DIR; $files = []; foreach (glob($dir . '/*.zip') as $path) { $name = basename($path); /* Skip the restore script wrapper if present */ if ($name === 'restore.php') { continue; } $files[] = [ 'name' => $name, 'path' => $path, 'size' => filesize($path), 'date' => date('Y-m-d H:i:s', filemtime($path)), ]; } /* Sort by modification time, newest first */ usort($files, fn($a, $b) => filemtime($b['path']) <=> filemtime($a['path'])); return $files; } /** * Handle backup file selection and set the working file. */ function getSelectedBackupFile(): string { if (!empty($_POST['backup_file'])) { $selected = basename($_POST['backup_file']); /* sanitize — basename only */ $path = RESTORE_DIR . '/' . $selected; if (is_file($path) && str_ends_with(strtolower($selected), '.zip')) { return $path; } } /* Auto-select if only one ZIP exists */ $backups = scanForBackups(); if (count($backups) === 1) { return $backups[0]['path']; } return ''; } SCANNER; /* Insert scanner after the opening PHP section but before the action handlers */ $php = str_replace( "/* ── Action Handlers", $scannerCode . "\n/* ── Action Handlers", $php ); /* Modify actionExtract to use getSelectedBackupFile() instead of BACKUP_FILE */ $php = str_replace( '$zip->open(BACKUP_FILE)', '$zip->open(getSelectedBackupFile() ?: BACKUP_FILE)', $php ); /* Modify the pre-checks to use getSelectedBackupFile() */ $php = str_replace( "file_exists(BACKUP_FILE)", "(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))", $php ); $html = self::generateFrontend(); /* Add backup file selector to the frontend before the extract step */ $selectorHtml = <<<'SELECTOR' SELECTOR; /* Insert the selector before the extract step in the HTML */ $html = str_replace( '', $selectorHtml . "\n", $html ); return $php . $html; } /** * Generate the standalone restore.php script. * * This is a self-contained PHP file with a Joomla-styled wizard UI that: * 1. Runs pre-installation checks (PHP, extensions, permissions) * 2. Extracts site-backup.zip to the current directory * 3. Imports database.sql using provided credentials * 4. Creates or updates configuration.php * 5. Resets super admin password (optional) * 6. Runs client provisioning tasks (optional) * 7. Cleans up restore artifacts */ private static function generateRestoreScript(): string { $php = self::generateBackend(); $html = self::generateFrontend(); return $php . $html; } /** * Generate the PHP backend portion of the restore script. */ private static function generateBackend(): string { return <<<'PHP_BACKEND' \n" . "MokoRestore Security Verification\n" . "==================================\n" . "Code: " . $securityCode . "\n" . "Enter this code in the MokoRestore browser interface to proceed.\n" . "This file will be deleted automatically after verification.\n"; if (file_put_contents($securityFile, $securityContent) === false) { // Cannot write security file — skip verification to avoid locking user out $_SESSION['security_verified'] = true; error_log('MokoRestore: Cannot write security file — verification skipped (check directory permissions)'); } } // Handle security code verification via POST if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'verify_security') { header('Content-Type: application/json; charset=utf-8'); $inputCode = strtoupper(trim($_POST['security_code'] ?? '')); if ($inputCode === $securityCode) { $_SESSION['security_verified'] = true; // Delete the security file if (is_file($securityFile)) { @unlink($securityFile); } echo json_encode(['success' => true, 'message' => 'Security verified']); } else { echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: .mokorestore-security.php']); } exit; } // Block all other actions until security is verified $securityVerified = !empty($_SESSION['security_verified']); // ── AJAX Handler ──────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { header('Content-Type: application/json; charset=utf-8'); if (!isset($_POST['token']) || !hash_equals($token, $_POST['token'])) { echo json_encode(['success' => false, 'message' => 'Invalid security token. Reload the page.']); exit; } if (!$securityVerified) { echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from .mokorestore-security.php']); exit; } @set_time_limit(0); @ini_set('max_execution_time', '0'); @ini_set('memory_limit', '512M'); @ignore_user_abort(true); try { $result = handleAction($_POST['action'], $_POST); echo json_encode($result); } catch (Throwable $e) { echo json_encode(['success' => false, 'message' => $e->getMessage()]); } exit; } function handleAction(string $action, array $data): array { return match ($action) { 'preflight' => actionPreflight(), 'extract' => actionExtract($data), 'testdb' => actionTestDb($data), 'database' => actionDatabase($data), 'config' => actionConfig($data), 'listAdmins' => actionListAdmins($data), 'resetAdmin' => actionResetAdmin($data), 'provision' => actionProvision($data), 'cleanup' => actionCleanup(), default => ['success' => false, 'message' => 'Unknown action: ' . $action], }; } function actionPreflight(): array { $checks = []; $checks[] = [ 'label' => 'PHP Version', 'value' => PHP_VERSION, 'ok' => version_compare(PHP_VERSION, '8.1', '>='), 'hint' => 'Joomla 4/5 requires PHP 8.1+', ]; $checks[] = [ 'label' => 'ZipArchive Extension', 'value' => extension_loaded('zip') ? 'Available' : 'Missing', 'ok' => extension_loaded('zip'), 'hint' => 'Required to extract backup archives', ]; $checks[] = [ 'label' => 'PDO MySQL', 'value' => extension_loaded('pdo_mysql') ? 'Available' : 'Missing', 'ok' => extension_loaded('pdo_mysql'), 'hint' => 'Required for database import', ]; $checks[] = [ 'label' => 'Multibyte String', 'value' => extension_loaded('mbstring') ? 'Available' : 'Missing', 'ok' => extension_loaded('mbstring'), 'hint' => 'Required by Joomla for UTF-8 handling', ]; $checks[] = [ 'label' => 'JSON Extension', 'value' => extension_loaded('json') ? 'Available' : 'Missing', 'ok' => extension_loaded('json'), 'hint' => 'Required by Joomla', ]; $checks[] = [ 'label' => 'Backup Archive', 'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found', 'ok' => file_exists(BACKUP_FILE), 'hint' => 'site-backup.zip must be in the same directory as restore.php', ]; $checks[] = [ 'label' => 'Directory Writable', 'value' => is_writable(RESTORE_DIR) ? 'Yes' : 'No', 'ok' => is_writable(RESTORE_DIR), 'hint' => 'The restore directory must be writable', ]; $freeSpace = @disk_free_space(RESTORE_DIR); $freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0; $checks[] = [ 'label' => 'Free Disk Space', 'value' => $freeGB . ' GB', 'ok' => $freeGB >= 0.5, 'hint' => 'At least 500 MB free space recommended', ]; $checks[] = [ 'label' => 'Memory Limit', 'value' => ini_get('memory_limit') ?: 'Unknown', 'ok' => true, 'hint' => 'Informational', ]; $allOk = true; foreach ($checks as $c) { if (!$c['ok']) { $allOk = false; } } return ['success' => $allOk, 'checks' => $checks]; } function actionExtract(array $data): array { if (!file_exists(BACKUP_FILE)) { throw new RuntimeException('Backup file not found: site-backup.zip'); } $zip = new ZipArchive(); if ($zip->open(BACKUP_FILE) !== true) { throw new RuntimeException('Cannot open backup archive'); } $password = trim($data['archive_password'] ?? ''); if ($password !== '') { $zip->setPassword($password); } // 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(RESTORE_DIR)) { $zip->close(); throw new RuntimeException( 'Extraction failed. ' . ($password !== '' ? 'Check the decryption password.' : 'The archive may be encrypted — provide a password.') ); } $count = $zip->numFiles; $zip->close(); // Pre-fill from configuration.php.bak (sanitized backup) or // configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values. $existingConfig = []; $configFile = RESTORE_DIR . '/configuration.php.bak'; if (!is_file($configFile)) { $configFile = RESTORE_DIR . '/configuration.php'; } if (is_file($configFile)) { $content = file_get_contents($configFile); $fieldMap = [ 'host' => 'db_host', 'db' => 'db_name', 'user' => 'db_user', 'dbprefix' => 'db_prefix', 'sitename' => 'sitename', 'smtphost' => 'smtp_host', 'smtpuser' => 'smtp_user', ]; foreach ($fieldMap as $phpField => $configKey) { if (preg_match('/\$' . preg_quote($phpField, '/') . '\s*=\s*\'([^\']*)\'/', $content, $m)) { if (strpos($m[1], '[SANITIZED:') === false) { $existingConfig[$configKey] = $m[1]; } } } } return [ 'success' => true, 'message' => "Extracted {$count} files", 'config' => $existingConfig, 'has_db' => is_file(RESTORE_DIR . '/database.sql'), ]; } function actionTestDb(array $data): array { $host = $data['db_host'] ?? 'localhost'; $name = $data['db_name'] ?? ''; $user = $data['db_user'] ?? ''; $pass = $data['db_pass'] ?? ''; if (empty($name) || empty($user)) { throw new RuntimeException('Database name and user are required'); } $pdo = new PDO( "mysql:host={$host};dbname={$name};charset=utf8mb4", $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5] ); $version = $pdo->query('SELECT VERSION()')->fetchColumn(); return ['success' => true, 'message' => 'Connected — MySQL ' . $version]; } function actionDatabase(array $data): array { $host = $data['db_host'] ?? 'localhost'; $name = $data['db_name'] ?? ''; $user = $data['db_user'] ?? ''; $pass = $data['db_pass'] ?? ''; if (empty($name) || empty($user)) { throw new RuntimeException('Database name and user are required'); } $sqlFile = RESTORE_DIR . '/database.sql'; if (!is_file($sqlFile)) { return ['success' => true, 'message' => 'No database.sql found — skipped', 'statements' => 0, 'errors' => 0]; } $pdo = new PDO( "mysql:host={$host};dbname={$name};charset=utf8mb4", $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); $pdo->exec('SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"'); $pdo->exec("SET time_zone = '+00:00'"); $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); $sql = file_get_contents($sqlFile); $prefix = getValidatedPrefix($data); // Replace abstract #__ prefix with the user's target prefix $sql = str_replace('#__', $prefix, $sql); $parts = explode(";\n", $sql); $statements = 0; $errors = 0; $errorList = []; foreach ($parts as $part) { $part = trim($part); if ($part === '' || str_starts_with($part, '--') || str_starts_with($part, 'SET ')) { continue; } try { $pdo->exec($part); $statements++; } catch (PDOException $e) { $errors++; if (count($errorList) < 5) { $errorList[] = substr($e->getMessage(), 0, 120); } } } $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); return [ 'success' => ($statements > 0 || $errors === 0), 'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''), 'statements' => $statements, 'errors' => $errors, 'errorList' => $errorList, ]; } function actionConfig(array $data): array { $host = $data['db_host'] ?? 'localhost'; $dbName = $data['db_name'] ?? ''; $dbUser = $data['db_user'] ?? ''; $dbPass = $data['db_pass'] ?? ''; $prefix = $data['db_prefix'] ?? 'moko_'; $sitename = $data['sitename'] ?? 'Joomla Site'; $livesite = $data['live_site'] ?? ''; $smtpHost = $data['smtp_host'] ?? ''; $smtpUser = $data['smtp_user'] ?? ''; $smtpPass = $data['smtp_pass'] ?? ''; $tmpPath = RESTORE_DIR . '/tmp'; $logPath = RESTORE_DIR . '/administrator/logs'; $configPath = RESTORE_DIR . '/configuration.php'; $bakPath = RESTORE_DIR . '/configuration.php.bak'; // Use .bak as the base template (preserves non-sensitive settings like // debug, cache, SEF, editor, etc.). Fall back to existing config // for legacy/unsanitized backups, or build from scratch if neither exists. $basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null); if ($basePath !== null) { $config = file_get_contents($basePath); // Replace all credential and server-specific fields with user input // Escape all user input for safe interpolation into PHP string literals $eHost = addcslashes($host, "'\\"); $eDbName = addcslashes($dbName, "'\\"); $eDbUser = addcslashes($dbUser, "'\\"); $eDbPass = addcslashes($dbPass, "'\\"); $ePrefix = addcslashes($prefix, "'\\"); $eSite = addcslashes($sitename, "'\\"); $eLive = addcslashes($livesite, "'\\"); $eSmtpH = addcslashes($smtpHost, "'\\"); $eSmtpU = addcslashes($smtpUser, "'\\"); $eSmtpP = addcslashes($smtpPass, "'\\"); $replacements = [ '/\$host\s*=\s*\'[^\']*\'/' => "\$host = '{$eHost}'", '/\$db\s*=\s*\'[^\']*\'/' => "\$db = '{$eDbName}'", '/\$user\s*=\s*\'[^\']*\'/' => "\$user = '{$eDbUser}'", '/\$password\s*=\s*\'[^\']*\'/' => "\$password = '{$eDbPass}'", '/\$dbprefix\s*=\s*\'[^\']*\'/' => "\$dbprefix = '{$ePrefix}'", '/\$tmp_path\s*=\s*\'[^\']*\'/' => "\$tmp_path = '{$tmpPath}'", '/\$log_path\s*=\s*\'[^\']*\'/' => "\$log_path = '{$logPath}'", '/\$sitename\s*=\s*\'[^\']*\'/' => "\$sitename = '{$eSite}'", '/\$secret\s*=\s*\'[^\']*\'/' => "\$secret = '" . bin2hex(random_bytes(16)) . "'", ]; if ($livesite !== '') { $replacements['/\$live_site\s*=\s*\'[^\']*\'/'] = "\$live_site = '{$eLive}'"; } // SMTP — always replace (clears sanitized placeholders even if blank) $replacements['/\$smtphost\s*=\s*\'[^\']*\'/'] = "\$smtphost = '{$eSmtpH}'"; $replacements['/\$smtpuser\s*=\s*\'[^\']*\'/'] = "\$smtpuser = '{$eSmtpU}'"; $replacements['/\$smtppass\s*=\s*\'[^\']*\'/'] = "\$smtppass = '{$eSmtpP}'"; // Clear remaining sanitized placeholders (proxy, Redis, DB TLS) $replacements['/\$proxy_user\s*=\s*\'[^\']*\'/'] = "\$proxy_user = ''"; $replacements['/\$proxy_pass\s*=\s*\'[^\']*\'/'] = "\$proxy_pass = ''"; $replacements['/\$redis_server_auth\s*=\s*\'[^\']*\'/'] = "\$redis_server_auth = ''"; $replacements['/\$session_redis_server_auth\s*=\s*\'[^\']*\'/'] = "\$session_redis_server_auth = ''"; $replacements['/\$dbsslkey\s*=\s*\'[^\']*\'/'] = "\$dbsslkey = ''"; $replacements['/\$dbsslcert\s*=\s*\'[^\']*\'/'] = "\$dbsslcert = ''"; $replacements['/\$dbsslca\s*=\s*\'[^\']*\'/'] = "\$dbsslca = ''"; foreach ($replacements as $pattern => $replacement) { $config = preg_replace($pattern, $replacement, $config); } if (file_put_contents($configPath, $config) === false) { return ['success' => false, 'message' => 'Failed to write Joomla config file — check directory permissions']; } // Remove .bak after successful rebuild if (is_file($bakPath)) { @unlink($bakPath); } // Reset .htaccess to Joomla defaults if requested $htWarn = ''; if (($data['reset_htaccess'] ?? '0') === '1') { $htWarn = writeDefaultHtaccess(RESTORE_DIR); } $msg = 'Joomla configuration rebuilt with fresh credentials and secret'; if ($htWarn !== '') { $msg .= ' (Warning: ' . $htWarn . ')'; } return ['success' => true, 'message' => $msg]; } // Create new configuration.php from scratch — use escaped values $eHost = addcslashes($host, "'\\"); $eDbName = addcslashes($dbName, "'\\"); $eDbUser = addcslashes($dbUser, "'\\"); $eDbPass = addcslashes($dbPass, "'\\"); $ePrefix = addcslashes($prefix, "'\\"); $eSite = addcslashes($sitename, "'\\"); $eLive = addcslashes($livesite, "'\\"); $secret = bin2hex(random_bytes(16)); $newConfig = << false, 'message' => 'Failed to write Joomla config file — check directory permissions']; } // Ensure directories exist @mkdir($tmpPath, 0755, true); @mkdir($logPath, 0755, true); // Reset .htaccess to Joomla defaults if requested $htWarn = ''; if (($data['reset_htaccess'] ?? '0') === '1') { $htWarn = writeDefaultHtaccess(RESTORE_DIR); } $msg = 'Joomla configuration created from scratch with fresh secret'; if ($htWarn !== '') { $msg .= ' (Warning: ' . $htWarn . ')'; } return ['success' => true, 'message' => $msg]; } /** * Write a clean Joomla default .htaccess file. * Backs up the existing one as .htaccess.bak first. */ function writeDefaultHtaccess(string $siteRoot): string { $htaccess = $siteRoot . '/.htaccess'; // Backup existing .htaccess before overwriting if (is_file($htaccess)) { if (!copy($htaccess, $htaccess . '.bak')) { return 'Could not back up existing .htaccess — reset skipped for safety'; } } $default = <<<'HTACCESS' ## # @package Joomla # @copyright (C) 2005 Open Source Matters, Inc. # @license GNU General Public License version 2 or later; see LICENSE.txt ## ## # READ THIS COMPLETELY IF YOU CHOOSE TO USE THIS FILE! # # The line 'Options +FollowSymLinks' may cause problems with some server # configurations. It is required for the use of Apache mod_rewrite, but # it may have already been set by your server administrator in a way that # disallows changing it in this .htaccess file. If using it causes your # server to report an error, comment it out, reload your site in your # browser and test your SEF URLs. If they work, then it has been set by # your server administrator and you do not need to set it here. ## ## No directory listings IndexIgnore * ## Suppress mime type detection in browsers for unknown types Header always set X-Content-Type-Options "nosniff" ## Can be commented out if causes errors, see notes above. Options +FollowSymLinks Options -Indexes ## Disable inline JavaScript when directly opening SVG files or embedding them with the object-tag Header always set Content-Security-Policy "script-src 'none'" ## Mod_rewrite in use. RewriteEngine On ## Begin - Rewrite rules to block out some common exploits. # If you experience problems on your site then comment out the operations listed # below by adding a # to the beginning of the line. # This attempts to block the most common type of exploit `attempts` on Joomla! # # Block any script trying to base64_encode data within the URL. RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR] # Block any script that includes a HTML_FRONTEND; } }