2f490c3208
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 8s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Strip sensitive credentials from configuration.php before adding it to the backup archive. Replaced fields use [SANITIZED:field] placeholders: - Database: host, user, password, db - Security: secret - SMTP: smtpuser, smtppass, smtphost - Proxy: proxy_user, proxy_pass - Redis: redis_server_auth, session_redis_server_auth - DB TLS: dbsslkey, dbsslcert, dbsslca Non-sensitive fields (sitename, debug, cache, SEF, paths, etc.) are preserved as-is for inspection and partial restores. MokoRestore detects sanitized placeholders and leaves those form fields blank so the user must enter fresh credentials (like the Joomla installer). In-Joomla restore is unaffected because RestoreEngine preserves the current site's configuration.php. Applied to both BackupEngine (synchronous) and SteppedBackupEngine (AJAX-based) code paths.
1240 lines
47 KiB
PHP
1240 lines
47 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.
|
|
*
|
|
* 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'];
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
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();
|
|
|
|
// Try to read existing configuration.php for pre-filling.
|
|
// Sanitized backups contain [SANITIZED:field] placeholders — skip those.
|
|
$existingConfig = [];
|
|
$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',
|
|
];
|
|
|
|
foreach ($fieldMap as $phpField => $configKey) {
|
|
if (preg_match('/\$' . preg_quote($phpField, '/') . '\s*=\s*\'([^\']*)\'/', $content, $m)) {
|
|
// Skip sanitized placeholder values
|
|
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);
|
|
$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'] ?? '';
|
|
$tmpPath = RESTORE_DIR . '/tmp';
|
|
$logPath = RESTORE_DIR . '/administrator/logs';
|
|
|
|
$configFile = RESTORE_DIR . '/configuration.php';
|
|
|
|
if (is_file($configFile)) {
|
|
// Update existing configuration.php
|
|
$config = file_get_contents($configFile);
|
|
|
|
$replacements = [
|
|
'/\$host\s*=\s*\'[^\']*\'/' => "\$host = '{$host}'",
|
|
'/\$db\s*=\s*\'[^\']*\'/' => "\$db = '{$dbName}'",
|
|
'/\$user\s*=\s*\'[^\']*\'/' => "\$user = '{$dbUser}'",
|
|
'/\$password\s*=\s*\'[^\']*\'/' => "\$password = '" . addcslashes($dbPass, "'\\") . "'",
|
|
'/\$dbprefix\s*=\s*\'[^\']*\'/' => "\$dbprefix = '{$prefix}'",
|
|
'/\$tmp_path\s*=\s*\'[^\']*\'/' => "\$tmp_path = '{$tmpPath}'",
|
|
'/\$log_path\s*=\s*\'[^\']*\'/' => "\$log_path = '{$logPath}'",
|
|
'/\$sitename\s*=\s*\'[^\']*\'/' => "\$sitename = '" . addcslashes($sitename, "'\\") . "'",
|
|
'/\$secret\s*=\s*\'[^\']*\'/' => "\$secret = '" . bin2hex(random_bytes(16)) . "'",
|
|
];
|
|
|
|
if ($livesite !== '') {
|
|
$replacements['/\$live_site\s*=\s*\'[^\']*\'/'] = "\$live_site = '{$livesite}'";
|
|
}
|
|
|
|
foreach ($replacements as $pattern => $replacement) {
|
|
$config = preg_replace($pattern, $replacement, $config);
|
|
}
|
|
|
|
file_put_contents($configFile, $config);
|
|
|
|
return ['success' => true, 'message' => 'configuration.php updated with new settings and fresh secret'];
|
|
}
|
|
|
|
// Create new configuration.php from scratch
|
|
$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 = '{$sitename}';
|
|
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 = '{$host}';
|
|
public \$user = '{$dbUser}';
|
|
public \$password = '{$dbPass}';
|
|
public \$db = '{$dbName}';
|
|
public \$dbprefix = '{$prefix}';
|
|
public \$dbencryption = 0;
|
|
public \$dbsslverifyservercert = false;
|
|
public \$dbsslkey = '';
|
|
public \$dbsslcert = '';
|
|
public \$dbsslca = '';
|
|
public \$dbsslcipher = '';
|
|
public \$force_ssl = 0;
|
|
public \$live_site = '{$livesite}';
|
|
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;
|
|
|
|
file_put_contents($configFile, $newConfig);
|
|
|
|
// Ensure directories exist
|
|
@mkdir($tmpPath, 0755, true);
|
|
@mkdir($logPath, 0755, true);
|
|
|
|
return ['success' => true, 'message' => 'configuration.php created from scratch with fresh secret'];
|
|
}
|
|
|
|
function actionListAdmins(array $data): array
|
|
{
|
|
$pdo = getDbConnection($data);
|
|
$prefix = $data['db_prefix'] ?? 'moko_';
|
|
|
|
// 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 = $data['db_prefix'] ?? 'moko_';
|
|
$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 = $data['db_prefix'] ?? 'moko_';
|
|
$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';
|
|
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
|
|
$name = $data['db_name'] ?? '';
|
|
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
|
|
$user = $data['db_user'] ?? '';
|
|
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
|
|
$pass = $data['db_pass'] ?? '';
|
|
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
|
|
|
|
// Validate db_prefix to prevent SQL injection
|
|
$prefix = $data['db_prefix'] ?? 'moko_';
|
|
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
|
|
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$\/', $prefix)) {
|
|
throw new RuntimeException('Invalid table prefix format');
|
|
}
|
|
|
|
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 — 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 1: Pre-flight Checks -->
|
|
<div class="mr-panel 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">Update or create configuration.php with the correct settings for this server.</p>
|
|
<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 class="mr-alert mr-alert-info">
|
|
A new Joomla secret will be generated automatically for security.
|
|
</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 — <a href="https://mokoconsulting.tech" target="_blank">Moko Consulting</a>
|
|
— 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 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 DB config from extracted configuration.php
|
|
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.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,
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|