feat: add restore engine and Kickstart self-extracting archives

- Add RestoreEngine: extract ZIP, restore files, import DB, preserve
  configuration.php, clean up staging directory
- Add FileRestorer: recursive file copy with protected file handling
  (skips configuration.php, .htaccess at root level)
- Add DatabaseImporter: streaming line-by-line SQL execution with
  comment/multiline handling and error tolerance
- Add Kickstart: standalone restore.php generator with web UI for
  restoring on blank servers (like Akeeba Kickstart Pro)
  - Pre-flight checks (PHP version, zip ext, writable)
  - Step-by-step: extract, import DB, update config, cleanup
  - Dark theme UI, CSRF protection, no dependencies
- Add "Include Restore Script" toggle per profile — wraps backup as
  outer.zip containing restore.php + site-backup.zip
- Add restore button to admin backups toolbar
- Fix innerHTML XSS risk (use DOM methods instead)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 14:58:21 -05:00
parent 4a159bab39
commit 5502c19a5d
11 changed files with 944 additions and 0 deletions
+5
View File
@@ -11,6 +11,11 @@
- RemoteUploaderInterface for pluggable storage backends
- Remote upload integrated into BackupEngine as Step 3 after archive creation
- Option to delete local copy after successful remote upload (per-profile setting)
- Restore engine with file restoration and database import
- Standalone Kickstart restore script (restore.php) — self-contained site restoration without Joomla, like Akeeba Kickstart
- "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip
- FileRestorer class with protected file handling (preserves configuration.php, .htaccess)
- DatabaseImporter with streaming line-by-line SQL execution and error tolerance
- Per-profile archive settings: format, compression level, split size, backup directory
- Backup engine with step-based execution for large sites
- Database dumper with table-level granularity
@@ -68,6 +68,17 @@
default="administrator/components/com_mokobackup/backups"
maxlength="512"
/>
<field
name="include_kickstart"
type="radio"
label="COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART"
description="COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="sidebar" label="COM_MOKOBACKUP_FIELDSET_STATUS">
@@ -73,6 +73,8 @@ COM_MOKOBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
COM_MOKOBACKUP_FIELD_BACKUP_DIR="Backup Directory"
COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Relative path from Joomla root where backup archives are stored"
COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART="Include Restore Script"
COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART_DESC="Include a standalone restore.php inside the backup archive. This creates a self-contained package that can restore the site on a blank server without Joomla installed — like Akeeba Kickstart."
; Exclusion filter fields
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
@@ -143,5 +145,10 @@ COM_MOKOBACKUP_FIELDSET_REMOTE="Remote Storage"
COM_MOKOBACKUP_FIELDSET_FTP="FTP Settings"
COM_MOKOBACKUP_FIELDSET_GDRIVE="Google Drive Settings"
; Restore
COM_MOKOBACKUP_TOOLBAR_RESTORE="Restore"
COM_MOKOBACKUP_RESTORE_CONFIRM="WARNING: Restoring will overwrite your current site files and/or database. Are you sure you want to continue?"
; Errors
COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
`gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '',
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`include_kickstart` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include standalone restore.php in archive',
`published` TINYINT(1) NOT NULL DEFAULT 1,
`ordering` INT(11) NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoBackup\Administrator\Engine\RestoreEngine;
class BackupsController extends AdminController
{
@@ -79,4 +80,37 @@ class BackupsController extends AdminController
$app->close();
}
/**
* Restore from a backup record.
*
* @return void
*/
public function restore(): void
{
$this->checkToken();
$id = $this->input->getInt('id', 0);
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
$preserveConfig = (bool) $this->input->getInt('preserve_config', 1);
if (!$id) {
$this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error');
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
return;
}
$engine = new RestoreEngine();
$result = $engine->restore($id, $restoreFiles, $restoreDb, $preserveConfig);
if ($result['success']) {
$this->setMessage($result['message']);
} else {
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
}
}
@@ -141,6 +141,23 @@ class BackupEngine
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
$this->log('Archive created: ' . $sizeHuman);
// Step 2.5: Wrap with Kickstart restore script (if enabled)
$includeKickstart = (bool) ($profile->include_kickstart ?? false);
if ($includeKickstart) {
$this->log('Wrapping with Kickstart restore script...');
$kickstartName = str_replace('.zip', '-kickstart.zip', $archiveName);
$kickstartPath = $this->backupDir . '/' . $kickstartName;
Kickstart::wrap($archivePath, $kickstartPath);
// Replace the original archive with the wrapped one
@unlink($archivePath);
rename($kickstartPath, $archivePath);
$totalSize = filesize($archivePath);
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
$this->log('Kickstart archive created: ' . $sizeHuman);
}
$remoteFilename = '';
// Step 3: Remote upload (if configured)
@@ -0,0 +1,127 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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
*
* Imports a SQL dump file created by DatabaseDumper.
* Handles #__ prefix replacement, multi-statement execution,
* and DROP TABLE before CREATE TABLE for clean restores.
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class DatabaseImporter
{
/**
* Import a SQL dump file into the database.
*
* @param string $sqlFile Absolute path to the SQL dump file
*
* @return int Number of statements executed
*
* @throws \RuntimeException On import failure
*/
public function import(string $sqlFile): int
{
if (!is_file($sqlFile) || !is_readable($sqlFile)) {
throw new \RuntimeException('SQL file not readable: ' . $sqlFile);
}
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$handle = fopen($sqlFile, 'r');
if ($handle === false) {
throw new \RuntimeException('Cannot open SQL file: ' . $sqlFile);
}
$statementsExecuted = 0;
$currentStatement = '';
$inMultiLineComment = false;
try {
while (($line = fgets($handle)) !== false) {
$trimmed = trim($line);
// Skip empty lines
if ($trimmed === '') {
continue;
}
// Skip single-line comments
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
continue;
}
// Handle multi-line comments
if (str_starts_with($trimmed, '/*')) {
$inMultiLineComment = true;
}
if ($inMultiLineComment) {
if (str_contains($trimmed, '*/')) {
$inMultiLineComment = false;
}
continue;
}
// Accumulate the statement
$currentStatement .= $line;
// Check if statement is complete (ends with semicolon)
if (str_ends_with($trimmed, ';')) {
$statement = trim($currentStatement);
$currentStatement = '';
if (empty($statement)) {
continue;
}
// Replace the prefix from the dump with the current site prefix.
// The dump uses real table names (with the original prefix), but
// if restoring to a site with a different prefix we need to handle it.
// Our DatabaseDumper uses real names, so no replacement needed
// for same-site restores.
try {
$db->setQuery($statement);
$db->execute();
$statementsExecuted++;
} catch (\Exception $e) {
// Log but don't abort — some statements may fail on
// different MySQL versions (e.g. charset differences)
// but the overall restore should continue.
error_log('MokoBackup SQL import warning: ' . $e->getMessage());
}
}
}
// Execute any remaining statement without trailing semicolon
$remaining = trim($currentStatement);
if (!empty($remaining)) {
try {
$db->setQuery($remaining);
$db->execute();
$statementsExecuted++;
} catch (\Exception $e) {
error_log('MokoBackup SQL import warning (final): ' . $e->getMessage());
}
}
} finally {
fclose($handle);
}
return $statementsExecuted;
}
}
@@ -0,0 +1,123 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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
*
* Restores files from a staging directory to the Joomla root.
* Skips database.sql and sensitive files that should not be overwritten.
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
class FileRestorer
{
private string $sourceDir;
private string $targetDir;
/**
* Files that should never be overwritten during restore.
* configuration.php is handled separately by the RestoreEngine.
*/
private const SKIP_FILES = [
'configuration.php',
'.htaccess',
'web.config',
];
/**
* Files that are backup artifacts, not part of the site.
*/
private const EXCLUDE_FILES = [
'database.sql',
];
public function __construct(string $sourceDir, string $targetDir)
{
$this->sourceDir = rtrim($sourceDir, '/\\');
$this->targetDir = rtrim($targetDir, '/\\');
}
/**
* Copy files from staging to target, preserving directory structure.
*
* @return int Number of files restored
*/
public function restore(): int
{
$count = 0;
$this->restoreDirectory('', $count);
return $count;
}
private function restoreDirectory(string $relativePath, int &$count): void
{
$sourcePath = $this->sourceDir . ($relativePath ? '/' . $relativePath : '');
if (!is_dir($sourcePath)) {
return;
}
$handle = opendir($sourcePath);
if ($handle === false) {
return;
}
while (($entry = readdir($handle)) !== false) {
if ($entry === '.' || $entry === '..') {
continue;
}
$entryRelative = $relativePath ? $relativePath . '/' . $entry : $entry;
$entrySource = $sourcePath . '/' . $entry;
$entryTarget = $this->targetDir . '/' . $entryRelative;
if (is_dir($entrySource)) {
// Create target directory if it doesn't exist
if (!is_dir($entryTarget)) {
mkdir($entryTarget, 0755, true);
}
$this->restoreDirectory($entryRelative, $count);
} elseif (is_file($entrySource)) {
// Skip excluded files
if (in_array($entry, self::EXCLUDE_FILES, true)) {
continue;
}
// Skip protected files (only at root level)
if ($relativePath === '' && in_array($entry, self::SKIP_FILES, true)) {
continue;
}
// Ensure parent directory exists
$parentDir = dirname($entryTarget);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
// Copy file, preserving permissions
if (copy($entrySource, $entryTarget)) {
// Try to match original permissions
$perms = fileperms($entrySource);
if ($perms !== false) {
@chmod($entryTarget, $perms);
}
$count++;
}
}
}
closedir($handle);
}
}
@@ -0,0 +1,415 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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 script generator.
*
* When "Include Kickstart" is enabled on a profile, the backup archive
* is wrapped:
*
* outer.zip
* ├── restore.php ← Standalone restore script (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 — just like Akeeba Kickstart.
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
class Kickstart
{
/**
* 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 kickstart 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 that:
* 1. Provides a web UI for configuration (DB credentials, etc.)
* 2. Extracts site-backup.zip to the current directory
* 3. Imports database.sql using the provided credentials
* 4. Updates configuration.php with new settings
* 5. Cleans up after itself
*/
private static function generateRestoreScript(): string
{
return <<<'RESTORE_PHP'
<?php
/**
* MokoJoomBackup — Standalone Restore Script
*
* Upload this file alongside site-backup.zip to your server.
* Open restore.php in your browser and follow the steps.
*
* DELETE THIS FILE AFTER RESTORATION IS COMPLETE.
*
* @package MokoJoomBackup
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
set_time_limit(0);
define('MOKOBACKUP_RESTORE', 1);
define('RESTORE_DIR', __DIR__);
define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');
// ── Security: simple token to prevent CSRF ─────────────────────────
session_start();
if (empty($_SESSION['restore_token'])) {
$_SESSION['restore_token'] = bin2hex(random_bytes(16));
}
$token = $_SESSION['restore_token'];
// ── Handle AJAX actions ────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
if (!isset($_POST['token']) || $_POST['token'] !== $token) {
echo json_encode(['success' => false, 'message' => 'Invalid token']);
exit;
}
$action = $_POST['action'];
try {
switch ($action) {
case 'preflight':
$checks = [];
$checks['php_version'] = ['value' => PHP_VERSION, 'ok' => version_compare(PHP_VERSION, '8.1', '>=')];
$checks['zip_ext'] = ['value' => extension_loaded('zip') ? 'Yes' : 'No', 'ok' => extension_loaded('zip')];
$checks['backup_exists'] = ['value' => file_exists(BACKUP_FILE) ? 'Yes' : 'No', 'ok' => file_exists(BACKUP_FILE)];
$checks['writable'] = ['value' => is_writable(RESTORE_DIR) ? 'Yes' : 'No', 'ok' => is_writable(RESTORE_DIR)];
if (file_exists(BACKUP_FILE)) {
$checks['backup_size'] = ['value' => number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB', 'ok' => true];
}
$allOk = true;
foreach ($checks as $c) {
if (!$c['ok']) $allOk = false;
}
echo json_encode(['success' => $allOk, 'checks' => $checks]);
break;
case 'extract':
$zip = new ZipArchive();
if ($zip->open(BACKUP_FILE) !== true) {
throw new RuntimeException('Cannot open backup archive');
}
$zip->extractTo(RESTORE_DIR);
$count = $zip->numFiles;
$zip->close();
echo json_encode(['success' => true, 'message' => "Extracted {$count} files"]);
break;
case 'database':
$host = $_POST['db_host'] ?? 'localhost';
$name = $_POST['db_name'] ?? '';
$user = $_POST['db_user'] ?? '';
$pass = $_POST['db_pass'] ?? '';
$prefix = $_POST['db_prefix'] ?? 'moko_';
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]
);
$sqlFile = RESTORE_DIR . '/database.sql';
if (!file_exists($sqlFile)) {
echo json_encode(['success' => true, 'message' => 'No database.sql found — skipped']);
break;
}
$sql = file_get_contents($sqlFile);
$statements = 0;
$errors = 0;
// Split on semicolons (simple splitter for our dump format)
$parts = explode(";\n", $sql);
foreach ($parts as $part) {
$part = trim($part);
if (empty($part) || strpos($part, '--') === 0) {
continue;
}
try {
$pdo->exec($part);
$statements++;
} catch (PDOException $e) {
$errors++;
}
}
echo json_encode([
'success' => true,
'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''),
]);
break;
case 'config':
$host = $_POST['db_host'] ?? 'localhost';
$name = $_POST['db_name'] ?? '';
$user = $_POST['db_user'] ?? '';
$pass = $_POST['db_pass'] ?? '';
$prefix = $_POST['db_prefix'] ?? 'moko_';
$sitename = $_POST['sitename'] ?? 'Restored Site';
$livesite = $_POST['live_site'] ?? '';
$tmpPath = $_POST['tmp_path'] ?? RESTORE_DIR . '/tmp';
$logPath = $_POST['log_path'] ?? RESTORE_DIR . '/administrator/logs';
$configFile = RESTORE_DIR . '/configuration.php';
if (file_exists($configFile)) {
// Update existing configuration.php
$config = file_get_contents($configFile);
$replacements = [
'/\$host\s*=\s*\'[^\']*\'/' => "\$host = '{$host}'",
'/\$db\s*=\s*\'[^\']*\'/' => "\$db = '{$name}'",
'/\$user\s*=\s*\'[^\']*\'/' => "\$user = '{$user}'",
'/\$password\s*=\s*\'[^\']*\'/' => "\$password = '{$pass}'",
'/\$dbprefix\s*=\s*\'[^\']*\'/' => "\$dbprefix = '{$prefix}'",
'/\$tmp_path\s*=\s*\'[^\']*\'/' => "\$tmp_path = '{$tmpPath}'",
'/\$log_path\s*=\s*\'[^\']*\'/' => "\$log_path = '{$logPath}'",
];
if (!empty($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);
}
echo json_encode(['success' => true, 'message' => 'Configuration updated']);
break;
case 'cleanup':
// Remove restore artifacts
@unlink(RESTORE_DIR . '/database.sql');
@unlink(BACKUP_FILE);
// Don't delete restore.php here — user does it manually
echo json_encode(['success' => true, 'message' => 'Cleanup complete. DELETE restore.php manually!']);
break;
default:
echo json_encode(['success' => false, 'message' => 'Unknown action']);
}
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
exit;
}
// ── HTML UI ────────────────────────────────────────────────────────
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MokoJoomBackup — Site Restore</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 2rem; }
.container { max-width: 700px; margin: 0 auto; }
h1 { color: #00d4ff; margin-bottom: 0.5rem; }
.subtitle { color: #888; margin-bottom: 2rem; }
.card { background: #16213e; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; border: 1px solid #0f3460; }
.card h2 { color: #00d4ff; font-size: 1.1rem; margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; color: #aaa; font-size: 0.9rem; }
input[type="text"], input[type="password"] { width: 100%; padding: 0.5rem; border: 1px solid #0f3460; border-radius: 4px; background: #1a1a2e; color: #e0e0e0; margin-bottom: 0.8rem; }
.btn { display: inline-block; padding: 0.6rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; margin-right: 0.5rem; margin-top: 0.5rem; }
.btn-primary { background: #00d4ff; color: #1a1a2e; font-weight: bold; }
.btn-danger { background: #e94560; color: white; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.log { background: #0d1117; border: 1px solid #0f3460; border-radius: 4px; padding: 1rem; font-family: monospace; font-size: 0.85rem; white-space: pre-wrap; max-height: 300px; overflow-y: auto; margin-top: 1rem; }
.check-ok { color: #4caf50; }
.check-fail { color: #e94560; }
.warning { background: #e94560; color: white; padding: 1rem; border-radius: 4px; margin-bottom: 1rem; font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<h1>MokoJoomBackup Restore</h1>
<p class="subtitle">Standalone site restoration tool</p>
<div class="warning">DELETE this file (restore.php) immediately after restoration is complete!</div>
<div class="card">
<h2>Step 1: Pre-flight Checks</h2>
<div id="checks"></div>
<button class="btn btn-primary" onclick="runPreflight()">Run Checks</button>
</div>
<div class="card">
<h2>Step 2: Extract Files</h2>
<button class="btn btn-primary" onclick="runExtract()" id="btnExtract" disabled>Extract site-backup.zip</button>
</div>
<div class="card">
<h2>Step 3: Database Restore</h2>
<label>DB Host</label><input type="text" id="db_host" value="localhost">
<label>DB Name</label><input type="text" id="db_name" value="">
<label>DB User</label><input type="text" id="db_user" value="">
<label>DB Password</label><input type="password" id="db_pass" value="">
<label>Table Prefix</label><input type="text" id="db_prefix" value="moko_">
<button class="btn btn-primary" onclick="runDatabase()" id="btnDb" disabled>Import Database</button>
</div>
<div class="card">
<h2>Step 4: Update Configuration</h2>
<label>Site Name</label><input type="text" id="sitename" value="Restored Site">
<label>Live Site URL (optional)</label><input type="text" id="live_site" value="" placeholder="https://example.com">
<button class="btn btn-primary" onclick="runConfig()" id="btnConfig" disabled>Update configuration.php</button>
</div>
<div class="card">
<h2>Step 5: Cleanup</h2>
<button class="btn btn-danger" onclick="runCleanup()" id="btnCleanup" disabled>Remove Restore Artifacts</button>
</div>
<div class="log" id="log">Ready. Click "Run Checks" to begin.</div>
</div>
<script>
const TOKEN = <?php echo json_encode($token); ?>;
function log(msg) {
const el = document.getElementById('log');
el.textContent += '\n[' + new Date().toLocaleTimeString() + '] ' + msg;
el.scrollTop = el.scrollHeight;
}
async function post(action, extra = {}) {
const form = new URLSearchParams();
form.append('action', action);
form.append('token', TOKEN);
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();
}
async function runPreflight() {
log('Running pre-flight checks...');
const r = await post('preflight');
const el = document.getElementById('checks');
el.innerHTML = '';
for (const [name, check] of Object.entries(r.checks)) {
const div = document.createElement('div');
div.className = check.ok ? 'check-ok' : 'check-fail';
div.textContent = (check.ok ? '✓ ' : '✗ ') + name + ': ' + check.value;
el.appendChild(div);
}
if (r.success) {
document.getElementById('btnExtract').disabled = false;
log('All checks passed');
} else {
log('Some checks failed — fix issues before proceeding');
}
}
async function runExtract() {
log('Extracting site-backup.zip...');
const r = await post('extract');
log(r.success ? r.message : 'FAILED: ' + r.message);
if (r.success) document.getElementById('btnDb').disabled = false;
}
async function runDatabase() {
log('Importing database...');
const r = await post('database', {
db_host: document.getElementById('db_host').value,
db_name: document.getElementById('db_name').value,
db_user: document.getElementById('db_user').value,
db_pass: document.getElementById('db_pass').value,
db_prefix: document.getElementById('db_prefix').value,
});
log(r.success ? r.message : 'FAILED: ' + r.message);
if (r.success) document.getElementById('btnConfig').disabled = false;
}
async function runConfig() {
log('Updating configuration.php...');
const r = await post('config', {
db_host: document.getElementById('db_host').value,
db_name: document.getElementById('db_name').value,
db_user: document.getElementById('db_user').value,
db_pass: document.getElementById('db_pass').value,
db_prefix: document.getElementById('db_prefix').value,
sitename: document.getElementById('sitename').value,
live_site: document.getElementById('live_site').value,
tmp_path: '<?php echo addslashes(RESTORE_DIR); ?>/tmp',
log_path: '<?php echo addslashes(RESTORE_DIR); ?>/administrator/logs',
});
log(r.success ? r.message : 'FAILED: ' + r.message);
if (r.success) document.getElementById('btnCleanup').disabled = false;
}
async function runCleanup() {
log('Cleaning up...');
const r = await post('cleanup');
log(r.success ? r.message : 'FAILED: ' + r.message);
log('\n=== RESTORE COMPLETE ===');
log('IMPORTANT: Delete restore.php from your server NOW!');
}
</script>
</body>
</html>
RESTORE_PHP;
}
}
@@ -0,0 +1,203 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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
*
* Restore engine — extracts a backup archive and reimports the database.
*
* Steps:
* 1. Extract ZIP to a temp staging directory
* 2. Preserve current configuration.php (DB credentials, paths)
* 3. Restore files from staging to Joomla root
* 4. Import database.sql (if present in archive)
* 5. Restore preserved configuration.php
* 6. Clean up staging directory
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class RestoreEngine
{
private array $log = [];
private string $stagingDir;
/**
* Run a full restore from a backup record.
*
* @param int $recordId Backup record ID to restore from
* @param bool $restoreFiles Whether to restore files
* @param bool $restoreDb Whether to restore the database
* @param bool $preserveConfig Keep current configuration.php
*
* @return array{success: bool, message: string}
*/
public function restore(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true): array
{
$db = Factory::getDbo();
// Load backup record
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . $recordId);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
return ['success' => false, 'message' => 'Backup record not found: ' . $recordId];
}
if ($record->status !== 'complete') {
return ['success' => false, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
}
$archivePath = $record->absolute_path;
if (!is_file($archivePath) || !is_readable($archivePath)) {
return ['success' => false, 'message' => 'Backup archive not found: ' . $archivePath];
}
// Create staging directory
$this->stagingDir = JPATH_ROOT . '/tmp/mokobackup-restore-' . $record->tag;
if (is_dir($this->stagingDir)) {
$this->recursiveDelete($this->stagingDir);
}
mkdir($this->stagingDir, 0755, true);
try {
// Step 1: Extract archive to staging
$this->log('Extracting archive: ' . basename($archivePath));
$this->extractArchive($archivePath);
$this->log('Extraction complete');
// Step 2: Preserve configuration.php
$configBackup = '';
if ($preserveConfig && is_file(JPATH_ROOT . '/configuration.php')) {
$configBackup = file_get_contents(JPATH_ROOT . '/configuration.php');
$this->log('Current configuration.php preserved');
}
// Step 3: Restore files
if ($restoreFiles) {
$this->log('Restoring files...');
$restorer = new FileRestorer($this->stagingDir, JPATH_ROOT);
$fileCount = $restorer->restore();
$this->log('Files restored: ' . $fileCount);
}
// Step 4: Import database
if ($restoreDb) {
$sqlFile = $this->stagingDir . '/database.sql';
if (is_file($sqlFile)) {
$this->log('Importing database...');
$importer = new DatabaseImporter();
$tableCount = $importer->import($sqlFile);
$this->log('Database imported: ' . $tableCount . ' statements executed');
} else {
$this->log('No database.sql found in archive — skipping database restore');
}
}
// Step 5: Restore preserved configuration.php
if ($preserveConfig && !empty($configBackup)) {
file_put_contents(JPATH_ROOT . '/configuration.php', $configBackup);
$this->log('Configuration.php restored to pre-restore state');
}
// Step 6: Clean up staging
$this->recursiveDelete($this->stagingDir);
$this->log('Staging directory cleaned up');
$this->log('Restore complete');
return [
'success' => true,
'message' => 'Restore complete from: ' . basename($archivePath),
'log' => implode("\n", $this->log),
];
} catch (\Throwable $e) {
$this->log('FATAL: ' . $e->getMessage());
// Restore config even on failure
if ($preserveConfig && !empty($configBackup)) {
file_put_contents(JPATH_ROOT . '/configuration.php', $configBackup);
$this->log('Configuration.php restored after failure');
}
// Clean up staging on failure
if (is_dir($this->stagingDir)) {
$this->recursiveDelete($this->stagingDir);
}
return [
'success' => false,
'message' => 'Restore failed: ' . $e->getMessage(),
'log' => implode("\n", $this->log),
];
}
}
/**
* Extract a ZIP archive to the staging directory.
*/
private function extractArchive(string $archivePath): void
{
$zip = new \ZipArchive();
$result = $zip->open($archivePath);
if ($result !== true) {
throw new \RuntimeException('Cannot open archive (error code: ' . $result . ')');
}
if (!$zip->extractTo($this->stagingDir)) {
$zip->close();
throw new \RuntimeException('Failed to extract archive to staging directory');
}
$this->log('Extracted ' . $zip->numFiles . ' entries');
$zip->close();
}
/**
* Recursively delete a directory and all its contents.
*/
private function recursiveDelete(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
@rmdir($item->getPathname());
} else {
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
}
}
@@ -41,6 +41,7 @@ class HtmlView extends BaseHtmlView
{
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database');
ToolbarHelper::custom('backups.start', 'download', '', 'COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW', false);
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOBACKUP_TOOLBAR_RESTORE', true);
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
ToolbarHelper::preferences('com_mokobackup');
}