Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php
T
Jonathan Miller e62dba8f40
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 16s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 53s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 31s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 8m41s
feat: standalone restore script — separate file that scans for ZIPs (#107)
New MokoRestore mode: 'standalone' generates restore.php as a separate
file that scans its directory for ZIP backup archives and lets the user
choose which one to restore. Unlike 'wrapped' mode which bundles
restore.php inside the backup ZIP, standalone mode keeps both files
separate — ideal for remote servers where you SCP the backup.

Changes:
- MokoRestore::generateStandalone() — writes restore.php with ZIP scanner
- Profile form: include_mokorestore now a dropdown (none/wrapped/standalone)
- BackupEngine: standalone mode writes restore.php + uploads to remote
- Restore script uses safe DOM methods (no innerHTML with user data)

Closes #107
2026-06-23 11:20:23 -05:00

1790 lines
68 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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'
<!-- Backup File Selector (standalone mode) -->
<div id="mr-step-select" class="mr-step" style="display:none;">
<h2 class="mr-step-title">Select Backup File</h2>
<p class="mr-desc">Choose which backup archive to restore from.</p>
<div id="mr-backup-list"></div>
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
</div>
<script>
(function() {
var backups = <?php echo json_encode(scanForBackups()); ?>;
var list = document.getElementById('mr-backup-list');
var hiddenInput = document.getElementById('mr-backup-file');
if (backups.length === 0) {
var alert = document.createElement('div');
alert.className = 'mr-alert mr-alert-danger';
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
list.appendChild(alert);
} else if (backups.length === 1) {
hiddenInput.value = backups[0].name;
var found = document.createElement('div');
found.className = 'mr-alert mr-alert-success';
var strong = document.createElement('strong');
strong.textContent = backups[0].name;
found.appendChild(document.createTextNode('Found: '));
found.appendChild(strong);
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
list.appendChild(found);
} else {
var group = document.createElement('div');
group.className = 'mr-field-group';
backups.forEach(function(b) {
var label = document.createElement('label');
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
var radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'backup_choice';
radio.value = b.name;
radio.style.marginRight = '8px';
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
label.appendChild(radio);
var nameStrong = document.createElement('strong');
nameStrong.textContent = b.name;
label.appendChild(nameStrong);
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
group.appendChild(label);
});
list.appendChild(group);
}
})();
</script>
SELECTOR;
/* Insert the selector before the extract step in the HTML */
$html = str_replace(
'<!-- Step: Extract -->',
$selectorHtml . "\n<!-- Step: Extract -->",
$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'
<?php
/**
* MokoRestore — Standalone Site Installer
*
* Upload this file alongside site-backup.zip to your server.
* Open restore.php in your browser and follow the wizard.
*
* DELETE THIS FILE AFTER INSTALLATION IS COMPLETE.
*
* @package MokoSuiteBackup
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
ini_set('display_errors', 0);
define('MOKOJOOMBACKUP_RESTORE', 1);
define('RESTORE_DIR', __DIR__);
define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');
session_start();
if (empty($_SESSION['restore_token'])) {
$_SESSION['restore_token'] = bin2hex(random_bytes(16));
}
$token = $_SESSION['restore_token'];
// ── Security Verification ───────────────────────────────────────────
// Write a security file to the web root with a random code.
// The user must read the code from the file and enter it in the browser
// to prove they have filesystem access before any restore actions are allowed.
$securityFile = RESTORE_DIR . '/.mokorestore-security.php';
$securityCode = $_SESSION['security_code'] ?? '';
if (empty($securityCode)) {
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
$_SESSION['security_code'] = $securityCode;
$_SESSION['security_verified'] = false;
// Write security file with the code
$securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\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 = <<<JCONFIG
<?php
class JConfig {
public \$offline = false;
public \$offline_message = 'This site is down for maintenance.<br>Please check back again soon.';
public \$display_offline_message = 1;
public \$offline_image = '';
public \$sitename = '{$eSite}';
public \$editor = 'tinymce';
public \$captcha = '0';
public \$list_limit = 20;
public \$access = 1;
public \$debug = false;
public \$debug_lang = false;
public \$debug_lang_const = true;
public \$dbtype = 'mysqli';
public \$host = '{$eHost}';
public \$user = '{$eDbUser}';
public \$password = '{$eDbPass}';
public \$db = '{$eDbName}';
public \$dbprefix = '{$ePrefix}';
public \$dbencryption = 0;
public \$dbsslverifyservercert = false;
public \$dbsslkey = '';
public \$dbsslcert = '';
public \$dbsslca = '';
public \$dbsslcipher = '';
public \$force_ssl = 0;
public \$live_site = '{$eLive}';
public \$secret = '{$secret}';
public \$gzip = false;
public \$error_reporting = 'default';
public \$helpurl = 'https://help.joomla.org/proxy?keyref=Help{major}{minor}:{keyref}&lang={langcode}';
public \$tmp_path = '{$tmpPath}';
public \$log_path = '{$logPath}';
public \$lifetime = 15;
public \$session_handler = 'database';
public \$shared_session = false;
public \$session_metadata = true;
}
JCONFIG;
if (file_put_contents($configPath, $newConfig) === false) {
return ['success' => 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. <https://www.joomla.org>
# @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
<IfModule autoindex>
IndexIgnore *
</IfModule>
## Suppress mime type detection in browsers for unknown types
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
</IfModule>
## 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
<FilesMatch "\.svg$">
<IfModule mod_headers.c>
Header always set Content-Security-Policy "script-src 'none'"
</IfModule>
</FilesMatch>
## 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 <script> tag in URL.
RewriteCond %{QUERY_STRING} (<|%3C)([^s]*s)+cript.*(>|%3E) [NC,OR]
# Block any script trying to set a PHP GLOBALS variable via URL.
RewriteCond %{QUERY_STRING} GLOBALS(=|\[|\%[0-9A-Z]{0,2}) [OR]
# Block any script trying to modify a _REQUEST variable via URL.
RewriteCond %{QUERY_STRING} _REQUEST(=|\[|\%[0-9A-Z]{0,2})
# Return 403 Forbidden header and show the content of the root home page
RewriteRule .* index.php [F]
#
## End - Rewrite rules to block out some common exploits.
## Begin - Custom redirects
#
# If you need to redirect some pages, or set a canonical non-www to
# www redirect (or vice versa), place that code here. Ensure those
# redirects use the correct RewriteRule syntax and the [R=301,L] flags.
#
## End - Custom redirects
##
# Uncomment the following line if your webserver's URL
# is not directly related to physical file paths.
# Update Your Joomla! Directory (just / for root).
##
# RewriteBase /
## Begin - Joomla! core SEF Section.
#
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
#
# If the requested path and file is not /index.php and the request
# has not already been internally rewritten to the index.php script
RewriteCond %{REQUEST_URI} !^/index\.php
# and the requested path and file matches no existing file
RewriteCond %{REQUEST_FILENAME} !-f
# and the requested path and file matches no existing directory
RewriteCond %{REQUEST_FILENAME} !-d
# internally rewrite the request to the index.php script
RewriteRule .* index.php [L]
#
## End - Joomla! core SEF Section.
HTACCESS;
if (file_put_contents($htaccess, $default) === false) {
return 'Could not write .htaccess — check directory permissions';
}
return '';
}
function getValidatedPrefix(array $data): string
{
$prefix = trim($data['db_prefix'] ?? 'moko_');
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) {
throw new RuntimeException('Invalid table prefix format');
}
return $prefix;
}
function actionListAdmins(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
// Find super admin users (group 8 = Super Users in Joomla)
$stmt = $pdo->prepare(
"SELECT u.id, u.name, u.username, u.email
FROM {$prefix}users u
INNER JOIN {$prefix}user_usergroup_map m ON m.user_id = u.id
WHERE m.group_id = 8
ORDER BY u.id ASC"
);
$stmt->execute();
$admins = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($admins)) {
return ['success' => true, 'admins' => [], 'message' => 'No super admin users found'];
}
return ['success' => true, 'admins' => $admins];
}
function actionResetAdmin(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
$userId = (int) ($data['admin_id'] ?? 0);
$password = $data['new_password'] ?? '';
if ($userId < 1 || strlen($password) < 8) {
throw new RuntimeException('Select an admin and enter a password (8+ characters)');
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE {$prefix}users SET password = ?, requireReset = 0 WHERE id = ?");
$stmt->execute([$hash, $userId]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('User not found or password unchanged');
}
return ['success' => true, 'message' => 'Admin password updated successfully'];
}
function actionProvision(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
$results = [];
foreach ($tasks as $task) {
try {
switch ($task) {
case 'reset_hits':
$pdo->exec("UPDATE {$prefix}content SET hits = 0");
$results[] = 'Content hits reset to 0';
break;
case 'clear_sessions':
$pdo->exec("TRUNCATE TABLE {$prefix}session");
$results[] = 'Sessions cleared';
break;
case 'clear_cache':
// Clear Joomla cache tables
foreach (['cache', 'cache_extension'] as $tbl) {
try {
$pdo->exec("TRUNCATE TABLE {$prefix}{$tbl}");
} catch (PDOException $e) {
// Table may not exist
}
}
$results[] = 'Cache tables cleared';
break;
case 'clear_update_keys':
$pdo->exec("UPDATE {$prefix}update_sites SET extra_query = ''");
$results[] = 'Update site download keys cleared';
break;
case 'clear_updates':
$pdo->exec("DELETE FROM {$prefix}updates");
$results[] = 'Pending updates cleared';
break;
case 'clear_api_tokens':
try {
$pdo->exec("TRUNCATE TABLE {$prefix}user_keys");
$results[] = 'API tokens cleared';
} catch (PDOException $e) {
$results[] = 'API tokens: table not found (skipped)';
}
break;
case 'clear_action_logs':
try {
$pdo->exec("TRUNCATE TABLE {$prefix}action_logs");
$results[] = 'Action logs cleared';
} catch (PDOException $e) {
$results[] = 'Action logs: table not found (skipped)';
}
break;
case 'clear_mail_queue':
try {
$pdo->exec("TRUNCATE TABLE {$prefix}mail_queue");
$results[] = 'Mail queue cleared';
} catch (PDOException $e) {
$results[] = 'Mail queue: table not found (skipped)';
}
break;
default:
$results[] = "Unknown task: {$task}";
}
} catch (Throwable $e) {
$results[] = "Error ({$task}): " . $e->getMessage();
}
}
return ['success' => true, 'results' => $results, 'message' => count($results) . ' provisioning tasks completed'];
}
function actionCleanup(): array
{
$removed = [];
foreach (['database.sql', 'site-backup.zip'] as $file) {
$path = RESTORE_DIR . '/' . $file;
if (is_file($path) && @unlink($path)) {
$removed[] = $file;
}
}
return [
'success' => true,
'message' => 'Removed: ' . (empty($removed) ? '(none)' : implode(', ', $removed))
. '. IMPORTANT: Delete restore.php manually!',
];
}
function getDbConnection(array $data): PDO
{
$host = $data['db_host'] ?? 'localhost';
$name = $data['db_name'] ?? '';
$user = $data['db_user'] ?? '';
$pass = $data['db_pass'] ?? '';
// Validate db_prefix to prevent SQL injection (used by callers for table names)
getValidatedPrefix($data);
return new PDO(
"mysql:host={$host};dbname={$name};charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
}
// ── Render HTML UI ──────────────────────────────────────────────────
?>
PHP_BACKEND;
}
/**
* Generate the HTML/CSS/JS frontend of the restore script.
*/
private static function generateFrontend(): string
{
return <<<'HTML_FRONTEND'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MokoRestore — Site Installer</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:#f0f4f8;color:#333;line-height:1.6}
.mr-header{background:linear-gradient(135deg,#1a3c5e 0%,#2d6a9f 100%);color:#fff;padding:1.5rem 2rem;text-align:center}
.mr-header h1{font-size:1.6rem;font-weight:700;margin-bottom:0.2rem}
.mr-header p{font-size:0.9rem;opacity:0.85}
.mr-container{max-width:800px;margin:1.5rem auto;padding:0 1rem}
/* Step progress bar */
.mr-steps{display:flex;justify-content:center;margin-bottom:1.5rem;gap:0;flex-wrap:wrap}
.mr-step{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 1rem;font-size:0.8rem;color:#94a3b8;cursor:default;position:relative}
.mr-step .mr-num{width:28px;height:28px;border-radius:50%;border:2px solid #cbd5e1;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:0.75rem;background:#fff;flex-shrink:0}
.mr-step.active{color:#1e40af;font-weight:600}
.mr-step.active .mr-num{border-color:#2563eb;background:#2563eb;color:#fff}
.mr-step.done{color:#16a34a}
.mr-step.done .mr-num{border-color:#16a34a;background:#16a34a;color:#fff}
.mr-step::after{content:'';width:24px;height:2px;background:#cbd5e1;margin-left:0.5rem}
.mr-step:last-child::after{display:none}
.mr-step.done::after{background:#16a34a}
/* Panels */
.mr-panel{display:none;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.1);padding:2rem;margin-bottom:1rem}
.mr-panel.visible{display:block}
.mr-panel h2{font-size:1.2rem;color:#1e293b;margin-bottom:0.25rem}
.mr-panel .mr-desc{color:#64748b;font-size:0.9rem;margin-bottom:1.5rem}
/* Form elements */
.mr-field{margin-bottom:1rem}
.mr-field label{display:block;font-weight:600;font-size:0.85rem;color:#475569;margin-bottom:0.3rem}
.mr-field input,.mr-field select{width:100%;padding:0.6rem 0.75rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;color:#1e293b;background:#fff;transition:border-color 0.2s}
.mr-field input:focus,.mr-field select:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,0.1)}
.mr-field .mr-hint{font-size:0.8rem;color:#94a3b8;margin-top:0.2rem}
.mr-row{display:flex;gap:1rem}
.mr-row .mr-field{flex:1}
/* Buttons */
.mr-btn{display:inline-flex;align-items:center;gap:0.4rem;padding:0.65rem 1.5rem;border:none;border-radius:6px;font-size:0.9rem;font-weight:600;cursor:pointer;transition:all 0.2s}
.mr-btn:disabled{opacity:0.5;cursor:not-allowed}
.mr-btn-primary{background:#2563eb;color:#fff}
.mr-btn-primary:hover:not(:disabled){background:#1d4ed8}
.mr-btn-success{background:#16a34a;color:#fff}
.mr-btn-success:hover:not(:disabled){background:#15803d}
.mr-btn-danger{background:#dc2626;color:#fff}
.mr-btn-danger:hover:not(:disabled){background:#b91c1c}
.mr-btn-outline{background:transparent;color:#2563eb;border:1px solid #2563eb}
.mr-btn-outline:hover:not(:disabled){background:#eff6ff}
.mr-actions{display:flex;justify-content:space-between;align-items:center;margin-top:1.5rem;padding-top:1rem;border-top:1px solid #e2e8f0}
/* Check list */
.mr-checks{list-style:none;margin:0;padding:0}
.mr-checks li{display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0;border-bottom:1px solid #f1f5f9;font-size:0.9rem}
.mr-checks li:last-child{border-bottom:none}
.mr-check-icon{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;flex-shrink:0}
.mr-check-ok{background:#dcfce7;color:#16a34a}
.mr-check-fail{background:#fef2f2;color:#dc2626}
.mr-check-info{background:#e0f2fe;color:#0284c7}
.mr-check-label{flex:1;font-weight:500}
.mr-check-value{color:#64748b;font-size:0.85rem}
.mr-check-hint{font-size:0.8rem;color:#94a3b8}
/* Alert */
.mr-alert{padding:0.75rem 1rem;border-radius:6px;font-size:0.9rem;margin-bottom:1rem;display:flex;align-items:flex-start;gap:0.5rem}
.mr-alert-warn{background:#fef3c7;color:#92400e;border:1px solid #fde68a}
.mr-alert-danger{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}
.mr-alert-success{background:#f0fdf4;color:#166534;border:1px solid #bbf7d0}
.mr-alert-info{background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe}
/* Progress */
.mr-progress{background:#e2e8f0;border-radius:8px;overflow:hidden;height:8px;margin:1rem 0}
.mr-progress-bar{height:100%;background:linear-gradient(90deg,#2563eb,#3b82f6);transition:width 0.4s;border-radius:8px}
.mr-status{font-size:0.85rem;color:#64748b;min-height:1.2rem}
/* Provisioning checkboxes */
.mr-provision-list{list-style:none;padding:0;margin:0}
.mr-provision-list li{display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0.5rem;border-bottom:1px solid #f1f5f9;font-size:0.9rem;cursor:pointer;border-radius:4px}
.mr-provision-list li:hover{background:#f8fafc}
.mr-provision-list li:last-child{border-bottom:none}
.mr-provision-list input[type=checkbox]{width:18px;height:18px;accent-color:#2563eb}
.mr-provision-desc{font-size:0.8rem;color:#94a3b8;margin-left:auto}
/* Log */
.mr-log{background:#1e293b;color:#94a3b8;border-radius:8px;padding:1rem;font-family:'SF Mono',SFMono-Regular,Consolas,monospace;font-size:0.8rem;max-height:200px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;margin-top:1rem;display:none}
.mr-log.visible{display:block}
/* Footer */
.mr-footer{text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.8rem}
.mr-footer a{color:#2563eb;text-decoration:none}
/* Spinner */
.mr-spinner{display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,0.3);border-radius:50%;border-top-color:#fff;animation:spin 0.6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:600px){.mr-row{flex-direction:column;gap:0}.mr-steps{gap:0}.mr-step{padding:0.4rem 0.5rem;font-size:0.7rem}.mr-step::after{width:12px}}
</style>
</head>
<body>
<div class="mr-header">
<h1>MokoRestore</h1>
<p>Standalone Site Installer &mdash; MokoSuiteBackup</p>
</div>
<div class="mr-container">
<div class="mr-alert mr-alert-danger">
<strong>Security:</strong> Delete restore.php immediately after installation is complete.
</div>
<!-- Step Progress -->
<div class="mr-steps" id="stepBar">
<div class="mr-step active" data-step="1"><span class="mr-num">1</span>Checks</div>
<div class="mr-step" data-step="2"><span class="mr-num">2</span>Extract</div>
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Database</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Configuration</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Admin</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Provisioning</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
</div>
<!-- Step 0: Security Verification -->
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
<h2>Security Verification</h2>
<p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>.mokorestore-security.php</code> in your site root.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128274;</span> How to find the code
</div>
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
<li>Connect to your server via FTP, SSH, or file manager</li>
<li>Open <code>.mokorestore-security.php</code> in the site root directory</li>
<li>Copy the 8-character code and enter it below</li>
</ol>
</div>
<div class="mr-field">
<label>Security Code</label>
<input type="text" id="securityCode" placeholder="e.g. A1B2C3D4" maxlength="8" style="text-transform:uppercase;letter-spacing:0.2em;font-family:monospace;font-size:1.1rem;text-align:center">
</div>
<div class="mr-status" id="securityStatus"></div>
<div class="mr-actions">
<span></span>
<button class="mr-btn mr-btn-primary" id="btnVerify" onclick="verifySecurity()">Verify &amp; Continue</button>
</div>
</div>
<!-- Step 1: Pre-flight Checks -->
<div class="mr-panel <?php echo $securityVerified ? 'visible' : ''; ?>" id="panel1">
<h2>Pre-Installation Checks</h2>
<p class="mr-desc">Verify your server meets the requirements for Joomla and MokoRestore.</p>
<ul class="mr-checks" id="checkList"></ul>
<div class="mr-actions">
<span></span>
<button class="mr-btn mr-btn-primary" id="btnCheck" onclick="runPreflight()">Run Checks</button>
</div>
</div>
<!-- Step 2: Extract Files -->
<div class="mr-panel" id="panel2">
<h2>Extract Backup</h2>
<p class="mr-desc">Extract site-backup.zip into the current directory.</p>
<div class="mr-field">
<label>Archive Password <span style="font-weight:normal;color:#94a3b8">(leave blank if not encrypted)</span></label>
<input type="password" id="archivePassword" placeholder="Optional decryption password">
</div>
<div class="mr-progress"><div class="mr-progress-bar" id="extractProgress" style="width:0%"></div></div>
<div class="mr-status" id="extractStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(1)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnExtract" onclick="runExtract()">Extract Files</button>
</div>
</div>
<!-- Step 3: Database -->
<div class="mr-panel" id="panel3">
<h2>Database Configuration</h2>
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
<div class="mr-row">
<div class="mr-field"><label>Database Host</label><input type="text" id="dbHost" value="localhost"></div>
<div class="mr-field"><label>Table Prefix</label><input type="text" id="dbPrefix" value="moko_"></div>
</div>
<div class="mr-field"><label>Database Name</label><input type="text" id="dbName" placeholder="joomla_db"></div>
<div class="mr-row">
<div class="mr-field"><label>Database User</label><input type="text" id="dbUser" placeholder="db_user"></div>
<div class="mr-field"><label>Database Password</label><input type="password" id="dbPass" placeholder="db_password"></div>
</div>
<div style="margin-bottom:1rem">
<button class="mr-btn mr-btn-outline" onclick="testDatabase()">Test Connection</button>
<span class="mr-status" id="dbTestStatus"></span>
</div>
<div class="mr-progress"><div class="mr-progress-bar" id="dbProgress" style="width:0%"></div></div>
<div class="mr-status" id="dbStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
</div>
</div>
<!-- Step 4: Site Configuration -->
<div class="mr-panel" id="panel4">
<h2>Site Configuration</h2>
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security &mdash; enter the correct values for this server.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#127760;</span> General
</div>
<div class="mr-field"><label>Site Name</label><input type="text" id="siteName" value="Joomla Site"></div>
<div class="mr-field">
<label>Live Site URL <span style="font-weight:normal;color:#94a3b8">(optional)</span></label>
<input type="text" id="liveSite" placeholder="https://example.com">
<div class="mr-hint">Leave blank to auto-detect. Set this if using a reverse proxy or custom domain.</div>
</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#9993;</span> Mail / SMTP <span style="font-weight:normal;font-size:0.8rem;color:#94a3b8">&mdash; leave blank if using PHP mail()</span>
</div>
<div class="mr-field"><label>SMTP Host</label><input type="text" id="smtpHost" placeholder="smtp.example.com"></div>
<div class="mr-row">
<div class="mr-field"><label>SMTP User</label><input type="text" id="smtpUser" placeholder="user@example.com"></div>
<div class="mr-field"><label>SMTP Password</label><input type="password" id="smtpPass" placeholder=""></div>
</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128736;</span> Server
</div>
<div class="mr-field" style="display:flex;align-items:center;gap:0.5rem">
<input type="checkbox" id="resetHtaccess" style="width:auto">
<label for="resetHtaccess" style="margin:0;cursor:pointer">Reset .htaccess to Joomla defaults</label>
</div>
<div class="mr-hint">Check this if restoring to a different server. The backup's .htaccess may contain server-specific rewrite rules that won't work here.</div>
</div>
<div class="mr-alert mr-alert-info">
<span>&#128274;</span> A new Joomla secret key will be generated automatically. This invalidates active sessions (users will need to log in again) but does not affect passwords or user accounts.
</div>
<div class="mr-status" id="configStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
</div>
</div>
<!-- Step 5: Admin Password Reset -->
<div class="mr-panel" id="panel5">
<h2>Super Admin Password</h2>
<p class="mr-desc">Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.</p>
<div class="mr-field">
<label>Select Super Admin</label>
<select id="adminSelect"><option value="">Loading...</option></select>
</div>
<div class="mr-field">
<label>New Password</label>
<input type="password" id="newAdminPass" placeholder="Minimum 8 characters">
</div>
<div class="mr-status" id="adminStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
</div>
</div>
</div>
<!-- Step 6: Client Provisioning -->
<div class="mr-panel" id="panel6">
<h2>Client Provisioning</h2>
<p class="mr-desc">Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.</p>
<ul class="mr-provision-list">
<li><input type="checkbox" class="prov-task" value="reset_hits" checked><span>Reset content hits</span><span class="mr-provision-desc">Set all article hit counters to 0</span></li>
<li><input type="checkbox" class="prov-task" value="clear_sessions" checked><span>Clear sessions</span><span class="mr-provision-desc">Remove all active user sessions</span></li>
<li><input type="checkbox" class="prov-task" value="clear_cache" checked><span>Clear cache</span><span class="mr-provision-desc">Truncate Joomla cache tables</span></li>
<li><input type="checkbox" class="prov-task" value="clear_update_keys"><span>Clear download keys</span><span class="mr-provision-desc">Remove update site extra_query keys</span></li>
<li><input type="checkbox" class="prov-task" value="clear_updates"><span>Clear pending updates</span><span class="mr-provision-desc">Remove cached update records</span></li>
<li><input type="checkbox" class="prov-task" value="clear_api_tokens"><span>Clear API tokens</span><span class="mr-provision-desc">Remove all personal access tokens</span></li>
<li><input type="checkbox" class="prov-task" value="clear_action_logs"><span>Clear action logs</span><span class="mr-provision-desc">Remove admin action log history</span></li>
<li><input type="checkbox" class="prov-task" value="clear_mail_queue"><span>Clear mail queue</span><span class="mr-provision-desc">Remove pending outbound emails</span></li>
</ul>
<div class="mr-status" id="provisionStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 7: Complete -->
<div class="mr-panel" id="panel7">
<h2>Installation Complete</h2>
<p class="mr-desc">Your Joomla site has been restored and configured.</p>
<div class="mr-alert mr-alert-success">
<strong>Success!</strong> The site restoration is complete.
</div>
<div class="mr-alert mr-alert-danger">
<strong>Important:</strong> Delete <code>restore.php</code> and <code>site-backup.zip</code> from your server immediately for security.
</div>
<div style="margin-top:1rem">
<button class="mr-btn mr-btn-danger" onclick="runCleanup()">Remove Restore Files</button>
<a class="mr-btn mr-btn-success" href="administrator/" style="text-decoration:none;margin-left:0.5rem">Open Joomla Admin</a>
<a class="mr-btn mr-btn-outline" href="./" style="text-decoration:none;margin-left:0.5rem">View Site</a>
</div>
<div class="mr-status" id="cleanupStatus" style="margin-top:1rem"></div>
</div>
<!-- Log -->
<div class="mr-log" id="logPanel"></div>
<div style="text-align:right;margin-top:0.5rem">
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.3rem 0.8rem" onclick="toggleLog()">Toggle Log</button>
</div>
</div>
<div class="mr-footer">
MokoRestore &mdash; <a href="https://mokoconsulting.tech" target="_blank">Moko Consulting</a>
&mdash; Part of MokoSuiteBackup
</div>
<script>
const TOKEN = <?php echo json_encode($token); ?>;
let currentStep = 1;
let dbConfig = {};
function log(msg) {
const el = document.getElementById('logPanel');
const ts = new Date().toLocaleTimeString();
el.textContent += '[' + ts + '] ' + msg + '\n';
el.scrollTop = el.scrollHeight;
}
function toggleLog() {
document.getElementById('logPanel').classList.toggle('visible');
}
async function post(action, extra) {
const form = new URLSearchParams();
form.append('action', action);
form.append('token', TOKEN);
if (extra) {
for (const [k, v] of Object.entries(extra)) {
form.append(k, v);
}
}
const res = await fetch('restore.php', { method: 'POST', body: form });
return res.json();
}
function goStep(n) {
currentStep = n;
document.querySelectorAll('.mr-panel').forEach(function(p) { p.classList.remove('visible'); });
const panel = document.getElementById('panel' + n);
if (panel) panel.classList.add('visible');
document.querySelectorAll('.mr-step').forEach(function(s) {
const sn = parseInt(s.dataset.step);
s.classList.remove('active', 'done');
if (sn === n) s.classList.add('active');
else if (sn < n) s.classList.add('done');
});
if (n === 5) loadAdmins();
}
function setStatus(id, msg, type) {
const el = document.getElementById(id);
el.textContent = msg;
el.style.color = type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : '#64748b';
}
function setBtnLoading(btn, loading) {
if (loading) {
btn.disabled = true;
btn.dataset.origText = btn.textContent;
btn.textContent = '';
var sp = document.createElement('span'); sp.className = 'mr-spinner'; btn.appendChild(sp);
btn.appendChild(document.createTextNode(' Working...'));
} else {
btn.disabled = false;
btn.textContent = btn.dataset.origText || 'Done';
}
}
// Step 1
async function verifySecurity() {
const btn = document.getElementById('btnVerify');
setBtnLoading(btn, true);
const code = document.getElementById('securityCode').value.trim();
if (!code) {
setStatus('securityStatus', 'Please enter the security code', 'error');
setBtnLoading(btn, false);
return;
}
const form = new FormData();
form.append('action', 'verify_security');
form.append('security_code', code);
form.append('token', TOKEN);
const resp = await fetch('', { method: 'POST', body: form });
const r = await resp.json();
setBtnLoading(btn, false);
if (r.success) {
setStatus('securityStatus', 'Verified!', 'success');
document.getElementById('panel0').classList.remove('visible');
document.getElementById('panel1').classList.add('visible');
} else {
setStatus('securityStatus', r.message, 'error');
}
}
async function runPreflight() {
const btn = document.getElementById('btnCheck');
setBtnLoading(btn, true);
log('Running pre-flight checks...');
const r = await post('preflight');
const list = document.getElementById('checkList');
while (list.firstChild) list.removeChild(list.firstChild);
r.checks.forEach(function(c) {
const li = document.createElement('li');
const icon = document.createElement('span');
icon.className = 'mr-check-icon ' + (c.ok ? 'mr-check-ok' : 'mr-check-fail');
icon.textContent = c.ok ? '\u2713' : '\u2717';
const label = document.createElement('span');
label.className = 'mr-check-label';
label.textContent = c.label;
const val = document.createElement('span');
val.className = 'mr-check-value';
val.textContent = c.value;
li.appendChild(icon);
li.appendChild(label);
li.appendChild(val);
list.appendChild(li);
log(' ' + (c.ok ? 'OK' : 'FAIL') + ': ' + c.label + ' = ' + c.value);
});
setBtnLoading(btn, false);
if (r.success) {
btn.textContent = 'Next \u2192';
btn.onclick = function() { goStep(2); };
btn.className = 'mr-btn mr-btn-success';
log('All checks passed');
} else {
btn.textContent = 'Re-check';
log('Some checks failed');
}
}
// Step 2
async function runExtract() {
const btn = document.getElementById('btnExtract');
setBtnLoading(btn, true);
document.getElementById('extractProgress').style.width = '50%';
setStatus('extractStatus', 'Extracting...', '');
log('Extracting backup archive...');
const pw = document.getElementById('archivePassword').value;
const r = await post('extract', pw ? { archive_password: pw } : {});
document.getElementById('extractProgress').style.width = '100%';
setBtnLoading(btn, false);
if (r.success) {
setStatus('extractStatus', r.message, 'success');
log(r.message);
// Pre-fill config from extracted configuration.php
// (sanitized fields will be absent — those form fields stay empty)
if (r.config) {
if (r.config.db_host) document.getElementById('dbHost').value = r.config.db_host;
if (r.config.db_name) document.getElementById('dbName').value = r.config.db_name;
if (r.config.db_user) document.getElementById('dbUser').value = r.config.db_user;
if (r.config.db_prefix) document.getElementById('dbPrefix').value = r.config.db_prefix;
if (r.config.sitename) document.getElementById('siteName').value = r.config.sitename;
if (r.config.smtp_host) document.getElementById('smtpHost').value = r.config.smtp_host;
if (r.config.smtp_user) document.getElementById('smtpUser').value = r.config.smtp_user;
}
if (!r.has_db) {
log('No database.sql found — database step will be skipped');
}
setTimeout(function() { goStep(3); }, 500);
} else {
setStatus('extractStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 3
function getDbParams() {
return {
db_host: document.getElementById('dbHost').value,
db_name: document.getElementById('dbName').value,
db_user: document.getElementById('dbUser').value,
db_pass: document.getElementById('dbPass').value,
db_prefix: document.getElementById('dbPrefix').value,
};
}
async function testDatabase() {
setStatus('dbTestStatus', 'Testing...', '');
const r = await post('testdb', getDbParams());
setStatus('dbTestStatus', r.success ? r.message : r.message, r.success ? 'success' : 'error');
log('DB test: ' + r.message);
}
async function runDatabase() {
const btn = document.getElementById('btnImport');
setBtnLoading(btn, true);
document.getElementById('dbProgress').style.width = '50%';
setStatus('dbStatus', 'Importing database...', '');
log('Importing database...');
dbConfig = getDbParams();
const r = await post('database', dbConfig);
document.getElementById('dbProgress').style.width = '100%';
setBtnLoading(btn, false);
if (r.success) {
setStatus('dbStatus', r.message, 'success');
log(r.message);
if (r.errorList && r.errorList.length > 0) {
r.errorList.forEach(function(e) { log(' Warning: ' + e); });
}
setTimeout(function() { goStep(4); }, 500);
} else {
setStatus('dbStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 4
async function runConfig() {
const btn = document.getElementById('btnConfig');
setBtnLoading(btn, true);
log('Updating configuration...');
const params = Object.assign({}, dbConfig, {
sitename: document.getElementById('siteName').value,
live_site: document.getElementById('liveSite').value,
smtp_host: document.getElementById('smtpHost').value,
smtp_user: document.getElementById('smtpUser').value,
smtp_pass: document.getElementById('smtpPass').value,
reset_htaccess: document.getElementById('resetHtaccess').checked ? '1' : '0',
});
const r = await post('config', params);
setBtnLoading(btn, false);
if (r.success) {
setStatus('configStatus', r.message, 'success');
log(r.message);
setTimeout(function() { goStep(5); }, 500);
} else {
setStatus('configStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 5
async function loadAdmins() {
const sel = document.getElementById('adminSelect');
while (sel.firstChild) sel.removeChild(sel.firstChild);
var loadOpt = document.createElement('option'); loadOpt.value = ''; loadOpt.textContent = 'Loading...'; sel.appendChild(loadOpt);
const r = await post('listAdmins', dbConfig);
while (sel.firstChild) sel.removeChild(sel.firstChild);
if (!r.success || !r.admins || r.admins.length === 0) {
var emptyOpt = document.createElement('option'); emptyOpt.value = ''; emptyOpt.textContent = 'No super admins found'; sel.appendChild(emptyOpt);
log('No super admin users found in database');
return;
}
r.admins.forEach(function(a) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.name + ' (' + a.username + ') — ' + a.email;
sel.appendChild(opt);
});
log('Found ' + r.admins.length + ' super admin(s)');
}
async function runResetAdmin() {
const btn = document.getElementById('btnResetAdmin');
const adminId = document.getElementById('adminSelect').value;
const password = document.getElementById('newAdminPass').value;
if (!adminId) { setStatus('adminStatus', 'Select an admin account', 'error'); return; }
if (password.length < 8) { setStatus('adminStatus', 'Password must be at least 8 characters', 'error'); return; }
setBtnLoading(btn, true);
log('Resetting admin password...');
const params = Object.assign({}, dbConfig, { admin_id: adminId, new_password: password });
const r = await post('resetAdmin', params);
setBtnLoading(btn, false);
if (r.success) {
setStatus('adminStatus', r.message, 'success');
log(r.message);
setTimeout(function() { goStep(6); }, 500);
} else {
setStatus('adminStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 6
async function runProvision() {
const btn = document.getElementById('btnProvision');
const tasks = [];
document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); });
if (tasks.length === 0) { goStep(7); return; }
setBtnLoading(btn, true);
log('Running ' + tasks.length + ' provisioning tasks...');
const params = Object.assign({}, dbConfig, { tasks: JSON.stringify(tasks) });
const r = await post('provision', params);
setBtnLoading(btn, false);
if (r.success) {
setStatus('provisionStatus', r.message, 'success');
r.results.forEach(function(msg) { log(' ' + msg); });
setTimeout(function() { goStep(7); }, 500);
} else {
setStatus('provisionStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 7
async function runCleanup() {
log('Cleaning up restore files...');
const r = await post('cleanup');
setStatus('cleanupStatus', r.message, r.success ? 'success' : 'error');
log(r.message);
}
// Auto-run preflight on load
document.addEventListener('DOMContentLoaded', function() { runPreflight(); });
</script>
</body>
</html>
HTML_FRONTEND;
}
}