Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php
T
jmiller 07fb4dcc24
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
fix: remove run/backup buttons, move actions to detail view, custom restore script name, version bump 01.43.11-dev
- Remove Run Backup / Backup Now buttons from profiles list, profile edit toolbar, and backup records view
- Move download, browse archive, and view log from backup list rows into individual backup record detail view
- Add download button to backup detail toolbar
- Link profile column in backup records list to profile edit
- Complete restore script filename customization across BackupEngine, SteppedBackupEngine, and MokoRestore
- Remove ordering field from profiles, default sort by ID ascending
- Fix untranslated JFIELD language keys
- Bump all manifests to 01.43.11-dev
2026-06-25 10:54:35 -05:00

2368 lines
92 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 $scriptName = 'restore.php'): string
{
$scriptName = self::sanitizeScriptName($scriptName);
$zip = new \ZipArchive();
if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath);
}
$zip->addFromString($scriptName, self::generateRestoreScript());
$zip->addFile($backupArchive, 'site-backup.zip');
$zip->close();
return $outputPath;
}
public static function sanitizeScriptName(string $name): string
{
$name = basename(trim($name));
if ($name === '' || !str_ends_with(strtolower($name), '.php')) {
$name = 'restore.php';
}
$name = preg_replace('/[^a-zA-Z0-9._-]/', '', $name);
return $name ?: 'restore.php';
}
/**
* 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
);
/* Replace the backup archive check with one that scans for ZIPs
(must run BEFORE the blanket file_exists replacement below) */
$php = str_replace(
<<<'ORIG'
$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 ' . basename($_SERVER['SCRIPT_NAME']),
];
ORIG,
<<<'REPL'
$availableBackups = scanForBackups();
$backupCount = count($availableBackups);
$selectedFile = getSelectedBackupFile();
if ($selectedFile && file_exists($selectedFile)) {
$archiveValue = basename($selectedFile) . ' (' . number_format(filesize($selectedFile) / 1048576, 2) . ' MB)';
} elseif ($backupCount > 0) {
$archiveValue = $backupCount . ' ZIP file(s) found';
} else {
$archiveValue = 'No ZIP files found';
}
$checks[] = [
'label' => 'Backup Archive',
'value' => $archiveValue,
'ok' => $backupCount > 0,
'hint' => 'Place one or more backup ZIP files in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
];
REPL
);
/* Modify remaining pre-checks to use getSelectedBackupFile() */
$php = str_replace(
"file_exists(BACKUP_FILE)",
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
$php
);
$html = self::generateFrontend();
/* Inject backup file selector into the extract step (panel2) */
$selectorHtml = <<<'SELECTOR'
<div id="mr-backup-selector" class="mb-3">
<label class="mr-field-label" style="font-weight:600;margin-bottom:8px;display:block;">Backup Archive</label>
<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.style.cssText = 'padding:12px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#dc2626;';
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.style.cssText = 'padding:12px;background:#dcfce7;border:1px solid #bbf7d0;border-radius:6px;color:#16a34a;';
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 hint = document.createElement('div');
hint.style.cssText = 'padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1d4ed8;margin-bottom:8px;font-size:0.9em;';
hint.textContent = 'Multiple backup archives found \u2014 select which one to restore:';
list.appendChild(hint);
backups.forEach(function(b, i) {
var label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;padding:10px 12px;margin:4px 0;border:1px solid #e2e8f0;border-radius:6px;cursor:pointer;transition:background 0.15s;';
label.onmouseover = function() { this.style.background = '#f8fafc'; };
label.onmouseout = function() { this.style.background = ''; };
var radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'backup_choice';
radio.value = b.name;
radio.style.marginRight = '10px';
if (i === 0) { radio.checked = true; hiddenInput.value = b.name; }
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
label.appendChild(radio);
var info = document.createElement('div');
var nameStrong = document.createElement('strong');
nameStrong.textContent = b.name;
info.appendChild(nameStrong);
var meta = document.createElement('div');
meta.style.cssText = 'font-size:0.85em;color:#64748b;margin-top:2px;';
meta.textContent = (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date;
info.appendChild(meta);
label.appendChild(info);
list.appendChild(label);
});
}
})();
</script>
SELECTOR;
/* Insert the selector into the extract panel */
$html = str_replace(
'<p class="mr-desc">Extract site-backup.zip into the current directory.</p>',
'<p class="mr-desc">Select a backup archive and extract it into the current directory.</p>' . "\n" . $selectorHtml,
$html
);
/* Pass selected backup file to the extract action */
$html = str_replace(
"const r = await post('extract', pw ? { archive_password: pw } : {});",
"var extraParams = {};\n" .
" if (pw) extraParams.archive_password = pw;\n" .
" var sel = document.getElementById('mr-backup-file');\n" .
" if (sel && sel.value) extraParams.backup_file = sel.value;\n" .
" const r = await post('extract', extraParams);",
$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),
'scanTables' => actionScanTables(),
'testdb' => actionTestDb($data),
'database' => actionDatabase($data),
'config' => actionConfig($data),
'listAdmins' => actionListAdmins($data),
'resetAdmin' => actionResetAdmin($data),
'postRestore' => actionPostRestore($data),
'detectSanitized' => detectSanitizedPasswords($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 ' . basename($_SERVER['SCRIPT_NAME']),
];
$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',
];
$joomlaExists = file_exists(RESTORE_DIR . '/configuration.php')
|| file_exists(RESTORE_DIR . '/libraries/src/Version.php');
$checks[] = [
'label' => 'Existing Installation',
'value' => $joomlaExists ? 'Joomla detected' : 'Clean directory',
'ok' => true,
'warn' => $joomlaExists,
'hint' => $joomlaExists
? 'WARNING: A Joomla installation already exists in this directory. Restoring will overwrite it.'
: 'No existing installation found — safe to proceed',
];
$allOk = true;
$warnings = [];
foreach ($checks as $c) {
if (!$c['ok']) {
$allOk = false;
}
if (!empty($c['warn'])) {
$warnings[] = $c['hint'];
}
}
return ['success' => $allOk, 'checks' => $checks, 'warnings' => $warnings];
}
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'),
];
}
/**
* Parse database.sql and extract the list of table names.
* Returns table names using the abstract #__ prefix so the UI
* can display them before the user's target prefix is known.
*/
function actionScanTables(): array
{
$sqlFile = RESTORE_DIR . '/database.sql';
if (!is_file($sqlFile)) {
return ['success' => true, 'tables' => [], 'message' => 'No database.sql found'];
}
$sql = file_get_contents($sqlFile);
$tables = [];
// Match DROP TABLE IF EXISTS `#__tablename` or CREATE TABLE ... `#__tablename`
if (preg_match_all('/(?:DROP\s+TABLE\s+IF\s+EXISTS|CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?)\s+`([^`]+)`/i', $sql, $matches)) {
foreach ($matches[1] as $name) {
if (!in_array($name, $tables, true)) {
$tables[] = $name;
}
}
}
// Sort alphabetically for easier scanning
sort($tables, SORT_STRING);
return [
'success' => true,
'tables' => $tables,
'count' => count($tables),
];
}
/**
* Determine which table a SQL statement belongs to.
* Returns the table name (with the prefix already applied) or empty string.
*/
function getStatementTable(string $stmt): string
{
// DROP TABLE IF EXISTS `prefix_tablename`
if (preg_match('/^DROP\s+TABLE\s+IF\s+EXISTS\s+`([^`]+)`/i', $stmt, $m)) {
return $m[1];
}
// CREATE TABLE `prefix_tablename` or CREATE TABLE IF NOT EXISTS `prefix_tablename`
if (preg_match('/^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`([^`]+)`/i', $stmt, $m)) {
return $m[1];
}
// INSERT INTO `prefix_tablename`
if (preg_match('/^INSERT\s+INTO\s+`([^`]+)`/i', $stmt, $m)) {
return $m[1];
}
return '';
}
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);
// Decode per-table conflict resolution selections
// Keys are abstract table names (#__xxx), values are: replace|skip|merge|dataonly
$tableResolutions = [];
if (!empty($data['table_resolutions'])) {
$decoded = json_decode($data['table_resolutions'], true);
if (is_array($decoded)) {
// Remap from abstract #__ names to the real prefix
foreach ($decoded as $abstractName => $mode) {
$realName = str_replace('#__', $prefix, $abstractName);
$tableResolutions[$realName] = $mode;
}
}
}
$parts = explode(";\n", $sql);
$statements = 0;
$errors = 0;
$errorList = [];
$skipped = 0;
foreach ($parts as $part) {
$part = trim($part);
if ($part === '' || str_starts_with($part, '--') || str_starts_with($part, 'SET ')) {
continue;
}
// Determine which table this statement belongs to
$table = getStatementTable($part);
$mode = $tableResolutions[$table] ?? 'replace';
// Apply conflict resolution per table
if ($mode === 'skip') {
$skipped++;
continue;
}
$isDrop = (bool) preg_match('/^DROP\s+TABLE/i', $part);
$isCreate = (bool) preg_match('/^CREATE\s+TABLE/i', $part);
$isInsert = (bool) preg_match('/^INSERT\s+INTO/i', $part);
if ($mode === 'merge') {
// Skip DROP and CREATE; convert INSERT INTO to INSERT IGNORE INTO
if ($isDrop || $isCreate) {
$skipped++;
continue;
}
if ($isInsert) {
$part = preg_replace('/^INSERT\s+INTO/i', 'INSERT IGNORE INTO', $part);
}
} elseif ($mode === 'dataonly') {
/* Skip DROP and CREATE; use REPLACE INTO for data (overwrites on duplicate key) */
if ($isDrop || $isCreate) {
$skipped++;
continue;
}
if ($isInsert) {
$part = preg_replace('/^INSERT\s+INTO/i', 'REPLACE INTO', $part);
}
}
// mode === 'replace' => execute everything as-is (default)
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');
$msg = "Executed {$statements} statements";
if ($skipped > 0) {
$msg .= " ({$skipped} skipped)";
}
if ($errors > 0) {
$msg .= " ({$errors} warnings)";
}
return [
'success' => ($statements > 0 || $errors === 0),
'message' => $msg,
'statements' => $statements,
'errors' => $errors,
'skipped' => $skipped,
'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 actionPostRestore(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_passwords':
/* Set all user passwords to a random temporary hash, block non-admin users */
$tempPassword = bin2hex(random_bytes(8)); /* 16-char random hex */
// clear activation tokens, and force password reset on next login.
$tempHash = password_hash($tempPassword, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
"UPDATE {$prefix}users SET password = ?, activation = '', requireReset = 1"
);
$stmt->execute([$tempHash]);
$affected = $stmt->rowCount();
$results[] = "All {$affected} user password(s) reset to temporary password ({$tempPassword}) with forced reset";
break;
case 'reset_hits':
$pdo->exec("UPDATE {$prefix}content SET hits = 0");
$results[] = 'Content hits reset to 0';
break;
case 'clear_versions':
try {
$pdo->exec("TRUNCATE TABLE {$prefix}history");
$results[] = 'Content version history cleared';
} catch (PDOException $e) {
$results[] = 'Version history: table not found (skipped)';
}
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
}
}
// Delete files in cache/ directory
$cacheDir = RESTORE_DIR . '/cache';
$cacheCount = 0;
if (is_dir($cacheDir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $item) {
if ($item->isFile()) {
@unlink($item->getPathname());
$cacheCount++;
} elseif ($item->isDir()) {
@rmdir($item->getPathname());
}
}
}
// Also clear administrator/cache/
$adminCacheDir = RESTORE_DIR . '/administrator/cache';
if (is_dir($adminCacheDir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($adminCacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $item) {
if ($item->isFile()) {
@unlink($item->getPathname());
$cacheCount++;
} elseif ($item->isDir()) {
@rmdir($item->getPathname());
}
}
}
$results[] = "Cache tables cleared, {$cacheCount} cache file(s) removed";
break;
default:
$results[] = "Unknown task: {$task}";
}
} catch (Throwable $e) {
$results[] = "Error ({$task}): " . $e->getMessage();
}
}
return ['success' => true, 'results' => $results, 'message' => count($results) . ' post-restore task(s) completed'];
}
/**
* Detect whether the database contains sanitized sentinel password hashes.
* Returns true if any user has the MokoSuiteBackup sanitized placeholder hash.
*/
function detectSanitizedPasswords(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
$sentinel = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$prefix}users WHERE password = ?");
$stmt->execute([$sentinel]);
$count = (int) $stmt->fetchColumn();
return ['success' => true, 'detected' => $count > 0, 'count' => $count];
}
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-warn{background:#fef9c3;color:#a16207}
.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 <code><?php echo htmlspecialchars(basename($_SERVER['SCRIPT_NAME'])); ?></code> 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>Tables</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Database</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Configuration</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Admin</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Post-Restore</div>
<div class="mr-step" data-step="8"><span class="mr-num">8</span>Provisioning</div>
<div class="mr-step" data-step="9"><span class="mr-num">9</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: Table Conflict Resolution -->
<div class="mr-panel" id="panel3">
<h2>Table Conflict Resolution</h2>
<p class="mr-desc">Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.</p>
<div style="margin-bottom:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('replace')">All Replace</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('skip')">All Skip</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('merge')">All Merge</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="presetExceptUsers()">Everything except users</button>
</div>
<div class="mr-alert mr-alert-info" style="font-size:0.85rem">
<strong>Modes:</strong>
<strong>Replace</strong> = drop + recreate + insert (default).
<strong>Skip</strong> = ignore entirely.
<strong>Merge</strong> = keep existing table, INSERT IGNORE new rows.
<strong>Data Only</strong> = keep schema, INSERT data as-is (assumes matching structure).
</div>
<div id="tableResolutionList" style="max-height:400px;overflow-y:auto;border:1px solid #e2e8f0;border-radius:8px;margin-top:1rem">
<div style="padding:1rem;color:#94a3b8;text-align:center">Scanning tables...</div>
</div>
<input type="hidden" id="tableResolutions" value="{}">
<div class="mr-status" id="tableScanStatus"></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="btnTablesContinue" onclick="goStep(4)">Continue to Database</button>
</div>
</div>
<!-- Step 4: Database -->
<div class="mr-panel" id="panel4">
<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(3)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
</div>
</div>
<!-- Step 5: Site Configuration -->
<div class="mr-panel" id="panel5">
<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(4)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
</div>
</div>
<!-- Step 6: Admin Password Reset -->
<div class="mr-panel" id="panel6">
<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(5)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
</div>
</div>
</div>
<!-- Step 7: Post-Restore Actions -->
<div class="mr-panel" id="panel7">
<h2>Post-Restore Actions</h2>
<p class="mr-desc">Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.</p>
<div class="mr-alert mr-alert-warn" id="postRestoreSanitizedWarn" style="display:none">
<strong>Sanitized passwords detected!</strong> This backup contains placeholder password hashes that will prevent all users from logging in. The "Reset all user passwords" option below is strongly recommended.
</div>
<ul class="mr-provision-list" id="postRestoreList">
<li><input type="checkbox" class="post-restore-task" id="prResetPasswords" value="reset_passwords"><span>Reset all user passwords</span><span class="mr-provision-desc">Set to random temporary password and force reset on next login</span></li>
<li><input type="checkbox" class="post-restore-task" value="reset_hits"><span>Reset content hits</span><span class="mr-provision-desc">Set all article hit counters to 0</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_versions"><span>Clear version history</span><span class="mr-provision-desc">Truncate the content version history table</span></li>
<li><input type="checkbox" class="post-restore-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="post-restore-task" value="clear_cache" checked><span>Clear cache</span><span class="mr-provision-desc">Truncate cache tables and delete cache files</span></li>
</ul>
<div class="mr-status" id="postRestoreStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(8)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnPostRestore" onclick="runPostRestore()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 8: Client Provisioning -->
<div class="mr-panel" id="panel8">
<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(7)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(9)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 9: Complete -->
<div class="mr-panel" id="panel9">
<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><?php echo htmlspecialchars(basename($_SERVER['SCRIPT_NAME'])); ?></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); ?>;
const SCRIPT_URL = <?php echo json_encode(basename($_SERVER['SCRIPT_NAME'])); ?>;
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);
}
}
var res;
try {
res = await fetch(SCRIPT_URL, { method: 'POST', body: form });
} catch (e) {
log('Network error: ' + e.message);
return { success: false, message: 'Network error: ' + e.message, checks: [] };
}
if (!res.ok) {
log('Server error: HTTP ' + res.status);
return { success: false, message: 'Server error (HTTP ' + res.status + ')', checks: [] };
}
try {
return await res.json();
} catch (e) {
log('Invalid response from server (not JSON)');
return { success: false, message: 'Invalid server response — check PHP error log', checks: [] };
}
}
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 === 3) scanTables();
if (n === 6) loadAdmins();
if (n === 7) checkSanitizedPasswords();
}
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...');
try {
const r = await post('preflight');
if (!r.success && !r.checks.length) {
log('Pre-flight error: ' + (r.message || 'Unknown error'));
setBtnLoading(btn, false);
btn.textContent = 'Re-check';
setStatus('checkList', r.message || 'Pre-flight check failed', 'error');
return;
}
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');
var iconClass = c.ok ? 'mr-check-ok' : 'mr-check-fail';
if (c.warn) iconClass = 'mr-check-warn';
icon.className = 'mr-check-icon ' + iconClass;
icon.textContent = c.warn ? '\u26a0' : (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);
if (c.warn && c.hint) {
var hint = document.createElement('div');
hint.style.cssText = 'font-size:0.85em;color:#a16207;margin-top:4px;padding:4px 8px;background:#fef9c3;border-radius:4px;';
hint.textContent = c.hint;
li.appendChild(hint);
}
list.appendChild(li);
var logPrefix = c.warn ? 'WARN' : (c.ok ? 'OK' : 'FAIL');
log(' ' + logPrefix + ': ' + 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');
}
} catch (e) {
log('Pre-flight error: ' + e.message);
setBtnLoading(btn, false);
btn.textContent = 'Re-check';
}
}
// 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: Table Conflict Resolution
let tableList = [];
async function scanTables() {
const container = document.getElementById('tableResolutionList');
// Only scan once
if (tableList.length > 0) return;
log('Scanning database.sql for table names...');
const r = await post('scanTables');
if (!r.success || !r.tables || r.tables.length === 0) {
container.innerHTML = '<div style="padding:1rem;color:#94a3b8;text-align:center">No tables found in database.sql (or file not present). You can skip this step.</div>';
setStatus('tableScanStatus', r.tables ? 'No tables found' : (r.message || 'Scan failed'), r.success ? '' : 'error');
log(r.message || 'No tables found');
return;
}
tableList = r.tables;
log('Found ' + r.count + ' tables');
setStatus('tableScanStatus', 'Found ' + r.count + ' tables', 'success');
renderTableList();
}
function renderTableList() {
const container = document.getElementById('tableResolutionList');
container.innerHTML = '';
var resolutions = {};
tableList.forEach(function(name) {
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.5rem 0.75rem;border-bottom:1px solid #f1f5f9;font-size:0.85rem;';
var label = document.createElement('span');
label.style.cssText = 'font-family:monospace;color:#334155;word-break:break-all;flex:1;margin-right:0.75rem;';
label.textContent = name;
var sel = document.createElement('select');
sel.dataset.table = name;
sel.className = 'table-mode-select';
sel.style.cssText = 'padding:0.3rem 0.5rem;border:1px solid #d1d5db;border-radius:4px;font-size:0.8rem;min-width:120px;background:#fff;';
var modes = [
['replace', 'Replace'],
['skip', 'Skip'],
['merge', 'Merge'],
['dataonly', 'Data Only']
];
modes.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m[0];
opt.textContent = m[1];
sel.appendChild(opt);
});
sel.addEventListener('change', updateTableResolutions);
row.appendChild(label);
row.appendChild(sel);
container.appendChild(row);
resolutions[name] = 'replace';
});
document.getElementById('tableResolutions').value = JSON.stringify(resolutions);
}
function updateTableResolutions() {
var resolutions = {};
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
resolutions[sel.dataset.table] = sel.value;
});
document.getElementById('tableResolutions').value = JSON.stringify(resolutions);
}
function setAllTableMode(mode) {
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
sel.value = mode;
});
updateTableResolutions();
log('Set all tables to: ' + mode);
}
function presetExceptUsers() {
var userTables = ['#__users', '#__user_usergroup_map', '#__user_profiles'];
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
var tableName = sel.dataset.table;
if (userTables.indexOf(tableName) !== -1) {
sel.value = 'skip';
} else {
sel.value = 'replace';
}
});
updateTableResolutions();
log('Preset: Replace all except user tables (skipped)');
}
// Step 4
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();
// Include table conflict resolution selections
var tableRes = document.getElementById('tableResolutions');
var dbParams = Object.assign({}, dbConfig, {
table_resolutions: tableRes ? tableRes.value : '{}'
});
const r = await post('database', dbParams);
document.getElementById('dbProgress').style.width = '100%';
setBtnLoading(btn, false);
if (r.success) {
setStatus('dbStatus', r.message, 'success');
log(r.message);
if (r.skipped && r.skipped > 0) {
log(' Skipped ' + r.skipped + ' statements due to conflict resolution');
}
if (r.errorList && r.errorList.length > 0) {
r.errorList.forEach(function(e) { log(' Warning: ' + e); });
}
setTimeout(function() { goStep(5); }, 500);
} else {
setStatus('dbStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 5
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(6); }, 500);
} else {
setStatus('configStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 6
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(7); }, 500);
} else {
setStatus('adminStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 7: Post-Restore
async function checkSanitizedPasswords() {
log('Checking for sanitized password hashes...');
try {
const r = await post('detectSanitized', dbConfig);
if (r.success && r.detected) {
document.getElementById('postRestoreSanitizedWarn').style.display = '';
document.getElementById('prResetPasswords').checked = true;
log('WARNING: ' + r.count + ' user(s) have sanitized placeholder passwords');
} else {
document.getElementById('postRestoreSanitizedWarn').style.display = 'none';
log('No sanitized passwords detected');
}
} catch (e) {
log('Could not check for sanitized passwords: ' + e.message);
}
}
async function runPostRestore() {
const btn = document.getElementById('btnPostRestore');
const tasks = [];
document.querySelectorAll('.post-restore-task:checked').forEach(function(cb) { tasks.push(cb.value); });
if (tasks.length === 0) { goStep(8); return; }
setBtnLoading(btn, true);
log('Running ' + tasks.length + ' post-restore task(s)...');
const params = Object.assign({}, dbConfig, { tasks: JSON.stringify(tasks) });
const r = await post('postRestore', params);
setBtnLoading(btn, false);
if (r.success) {
setStatus('postRestoreStatus', r.message, 'success');
r.results.forEach(function(msg) { log(' ' + msg); });
setTimeout(function() { goStep(8); }, 500);
} else {
setStatus('postRestoreStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 8
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(9); 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(9); }, 500);
} else {
setStatus('provisionStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 9
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;
}
}