feat: AJAX step-based backup engine for shared hosting

On shared hosting where max_execution_time cannot be overridden,
backups now run as a series of small AJAX requests. Each request
does one unit of work (dump one table, add one batch of files)
and returns within the time limit.

- SteppedBackupEngine: orchestrates init → database → files →
  finalize → upload → complete phases
- SteppedSession: persists state between requests via temp JSON files
  with automatic cleanup of stale sessions (24h)
- AjaxController: handles init and step requests with CSRF protection
- Admin UI: progress bar modal with real-time phase and percentage
  updates, auto-reloads on completion
- Steps: 1 table per DB step, 200 files per file step (configurable)

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 15:37:38 -05:00
parent e597896e73
commit 5e5d79327b
5 changed files with 942 additions and 0 deletions
+5
View File
@@ -16,6 +16,11 @@
- "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
- AJAX step-based backup engine for shared hosting (overcomes max_execution_time)
- SteppedBackupEngine: breaks backup into per-table DB dumps and file batches
- SteppedSession: persistent state between AJAX requests via temp JSON files
- Progress bar modal in admin UI with real-time phase/percentage updates
- AjaxController for init/step endpoints with CSRF protection
- 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
@@ -0,0 +1,85 @@
<?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
*
* AJAX controller for step-based backups.
* Handles init and step requests from the admin UI JavaScript.
*/
namespace Joomla\Component\MokoBackup\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoBackup\Administrator\Engine\SteppedBackupEngine;
class AjaxController extends BaseController
{
/**
* Initialize a new stepped backup.
* POST: task=ajax.init&profile_id=1&description=...
*/
public function init(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
return;
}
$profileId = $this->input->getInt('profile_id', 1);
$description = $this->input->getString('description', '');
$engine = new SteppedBackupEngine();
$result = $engine->init($profileId, $description, 'backend');
$this->sendJson($result);
}
/**
* Run the next step of a backup session.
* POST: task=ajax.step&session_id=mb_...
*/
public function step(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
return;
}
$sessionId = $this->input->getString('session_id', '');
if (empty($sessionId)) {
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
return;
}
$engine = new SteppedBackupEngine();
$result = $engine->runStep($sessionId);
$this->sendJson($result);
}
/**
* Send a JSON response and close the application.
*/
private function sendJson(array $data): void
{
$app = $this->app;
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
$app->sendHeaders();
echo json_encode($data);
$app->close();
}
}
@@ -0,0 +1,550 @@
<?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
*
* AJAX step-based backup engine for shared hosting.
*
* Each call to runStep() performs one unit of work within the PHP time
* limit, saves state, and returns. The browser JS fires the next step.
*
* This overcomes max_execution_time restrictions on shared hosting
* where ini_set() and set_time_limit() are disabled.
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class SteppedBackupEngine
{
/**
* Initialize a new stepped backup session.
*
* @return array{session_id: string, phase: string, progress: int, message: string}
*/
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
{
$db = Factory::getDbo();
// Load profile
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokobackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
if (!$profile) {
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
}
// Create session
$session = SteppedSession::create();
$session->profileId = $profileId;
$session->origin = $origin;
$session->backupType = $profile->backup_type;
// Parse profile settings
$session->excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? '');
$session->excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
$session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
$session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups';
$session->remoteStorage = $profile->remote_storage ?? 'none';
$session->includeKickstart = (bool) ($profile->include_kickstart ?? false);
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
// Build archive path
$backupDir = JPATH_ROOT . '/' . $session->backupDir;
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
$now = date('Y-m-d H:i:s');
$tag = date('Ymd_His');
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
$archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName;
$session->archiveName = $archiveName;
$session->description = $description ?: ($profile->title . ' — ' . $now);
// Create backup record
$record = (object) [
'profile_id' => $profileId,
'description' => $session->description,
'status' => 'running',
'origin' => $origin,
'backup_type' => $profile->backup_type,
'archivename' => $archiveName,
'absolute_path' => $session->archivePath,
'total_size' => 0,
'db_size' => 0,
'files_count' => 0,
'tables_count' => 0,
'multipart' => 0,
'tag' => $tag,
'backupstart' => $now,
'backupend' => '0000-00-00 00:00:00',
'filesexist' => 0,
'remote_filename' => '',
'log' => '',
];
$db->insertObject('#__mokobackup_records', $record, 'id');
$session->recordId = $record->id;
// Determine what work needs to be done and estimate steps
$totalSteps = 1; // init step
if ($profile->backup_type !== 'files') {
// Count tables for database phase
$tables = $this->getSiteTables($session->excludeTables);
$session->tables = $tables;
$totalSteps += count($tables); // one step per table
}
if ($profile->backup_type !== 'database') {
// Scan files and split into batches
$scanner = new FileScanner(JPATH_ROOT, $session->excludeDirs, $session->excludeFiles);
$allFiles = $scanner->scan();
$session->filesCount = count($allFiles);
$session->fileBatches = array_chunk($allFiles, $session->batchSize);
$totalSteps += count($session->fileBatches); // one step per batch
}
$totalSteps += 1; // finalize step
$totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step
$session->totalSteps = $totalSteps;
$session->currentStep = 1;
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
$session->log('Backup initialized: ' . $session->description);
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
$session->statusMessage = 'Initialized — starting backup...';
$session->save();
return [
'session_id' => $session->sessionId,
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
];
}
/**
* Run the next step of a backup session.
*
* @return array{session_id: string, phase: string, progress: int, message: string, done?: bool}
*/
public function runStep(string $sessionId): array
{
$session = SteppedSession::load($sessionId);
if (!$session) {
return ['error' => true, 'message' => 'Session not found: ' . $sessionId];
}
try {
switch ($session->phase) {
case 'database':
$this->stepDatabase($session);
break;
case 'files':
$this->stepFiles($session);
break;
case 'finalize':
$this->stepFinalize($session);
break;
case 'upload':
$this->stepUpload($session);
break;
case 'complete':
$session->destroy();
return [
'session_id' => $sessionId,
'phase' => 'complete',
'progress' => 100,
'message' => 'Backup complete: ' . $session->archiveName,
'done' => true,
];
}
$session->save();
return [
'session_id' => $sessionId,
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
'done' => $session->phase === 'complete',
];
} catch (\Throwable $e) {
$session->log('FATAL: ' . $e->getMessage());
$this->failRecord($session, $e->getMessage());
$session->destroy();
return ['error' => true, 'message' => 'Backup failed: ' . $e->getMessage()];
}
}
/**
* Database phase: dump one table per step.
*/
private function stepDatabase(SteppedSession $session): void
{
if ($session->tableIndex >= count($session->tables)) {
// Database phase complete, move to files or finalize
$session->phase = ($session->backupType !== 'database') ? 'files' : 'finalize';
$session->tablesCount = $session->tableIndex;
$session->log('Database dump complete: ' . $session->tablesCount . ' tables');
return;
}
$table = $session->tables[$session->tableIndex];
$db = Factory::getDbo();
// Dump this single table
$dumper = new DatabaseDumper([]);
$sql = $this->dumpSingleTable($db, $table);
// Append to a temp SQL file that will be added to ZIP in finalize
$sqlFile = $session->archivePath . '.sql';
$flags = $session->tableIndex === 0 ? 0 : FILE_APPEND;
if ($session->tableIndex === 0) {
$header = "-- MokoJoomBackup Database Dump\n"
. "-- Generated: " . date('Y-m-d H:i:s') . "\n"
. "-- Prefix: " . $db->getPrefix() . "\n\n"
. "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"
. "SET time_zone = \"+00:00\";\n\n";
file_put_contents($sqlFile, $header);
$flags = FILE_APPEND;
}
file_put_contents($sqlFile, $sql, $flags);
$session->dbSize += strlen($sql);
$session->tableIndex++;
$session->currentStep++;
$session->statusMessage = 'Dumping table ' . $session->tableIndex . '/' . count($session->tables) . ': ' . $table;
$session->log('Dumped table: ' . $table);
}
/**
* Files phase: add one batch of files to ZIP per step.
*/
private function stepFiles(SteppedSession $session): void
{
if ($session->batchIndex >= count($session->fileBatches)) {
$session->phase = 'finalize';
$session->log('Files phase complete: ' . $session->filesCount . ' files in ' . count($session->fileBatches) . ' batches');
return;
}
$batch = $session->fileBatches[$session->batchIndex];
$zip = new \ZipArchive();
$mode = $session->batchIndex === 0
? (\ZipArchive::CREATE | \ZipArchive::OVERWRITE)
: \ZipArchive::CREATE;
if ($zip->open($session->archivePath, $mode) !== true) {
throw new \RuntimeException('Cannot open archive for writing');
}
$added = 0;
foreach ($batch as $relativePath) {
$fullPath = JPATH_ROOT . '/' . $relativePath;
if (is_file($fullPath) && is_readable($fullPath)) {
$zip->addFile($fullPath, $relativePath);
$added++;
}
}
$zip->close();
$session->batchIndex++;
$session->currentStep++;
$batchNum = $session->batchIndex;
$totalBatches = count($session->fileBatches);
$session->statusMessage = "Adding files batch {$batchNum}/{$totalBatches} ({$added} files)";
$session->log("Files batch {$batchNum}: {$added} files added");
}
/**
* Finalize phase: add database.sql to ZIP, apply kickstart wrapper.
*/
private function stepFinalize(SteppedSession $session): void
{
$zip = new \ZipArchive();
if ($zip->open($session->archivePath, \ZipArchive::CREATE) !== true) {
throw new \RuntimeException('Cannot open archive for finalization');
}
// Add database dump if it exists
$sqlFile = $session->archivePath . '.sql';
if (is_file($sqlFile)) {
$zip->addFile($sqlFile, 'database.sql');
}
$zip->close();
// Clean up temp SQL file
if (is_file($sqlFile)) {
@unlink($sqlFile);
}
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
// Kickstart wrapper
if ($session->includeKickstart) {
$session->log('Wrapping with Kickstart restore script...');
$kickstartPath = $session->archivePath . '.kickstart.zip';
Kickstart::wrap($session->archivePath, $kickstartPath);
@unlink($session->archivePath);
rename($kickstartPath, $session->archivePath);
$totalSize = filesize($session->archivePath);
$session->log('Kickstart archive created');
}
// Update record
$db = Factory::getDbo();
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
$update = (object) [
'id' => $session->recordId,
'total_size' => $totalSize,
'db_size' => $session->dbSize,
'files_count' => $session->filesCount,
'tables_count' => $session->tablesCount,
'filesexist' => 1,
];
$db->updateObject('#__mokobackup_records', $update, 'id');
$session->currentStep++;
$session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete';
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
$session->log('Archive finalized: ' . $sizeHuman);
if ($session->phase === 'complete') {
$this->completeRecord($session);
}
}
/**
* Upload phase: send archive to remote storage.
*/
private function stepUpload(SteppedSession $session): void
{
$db = Factory::getDbo();
// Reload profile for remote settings
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokobackup_profiles'))
->where($db->quoteName('id') . ' = ' . $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
$uploader = match ($session->remoteStorage) {
'ftp' => new FtpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
};
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
$result = $uploader->upload($session->archivePath, $session->archiveName);
$remoteFilename = '';
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log('Remote upload complete: ' . $result['message']);
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed');
}
} else {
$session->log('WARNING: Remote upload failed: ' . $result['message']);
}
// Update record with remote filename
$update = (object) [
'id' => $session->recordId,
'remote_filename' => $remoteFilename,
'filesexist' => is_file($session->archivePath) ? 1 : 0,
];
$db->updateObject('#__mokobackup_records', $update, 'id');
$session->currentStep++;
$session->phase = 'complete';
$session->statusMessage = 'Backup complete';
$this->completeRecord($session);
}
/**
* Mark the backup record as complete.
*/
private function completeRecord(SteppedSession $session): void
{
$db = Factory::getDbo();
$update = (object) [
'id' => $session->recordId,
'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $session->log),
];
$db->updateObject('#__mokobackup_records', $update, 'id');
}
/**
* Mark the backup record as failed.
*/
private function failRecord(SteppedSession $session, string $error): void
{
$db = Factory::getDbo();
$update = (object) [
'id' => $session->recordId,
'status' => 'fail',
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $session->log),
];
$db->updateObject('#__mokobackup_records', $update, 'id');
}
/**
* Dump a single table to SQL string.
*/
private function dumpSingleTable(object $db, string $table): string
{
$output = [];
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- --------------------------------------------------------';
$output[] = '';
// CREATE TABLE
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow();
if (!$createRow || empty($createRow[1])) {
return '';
}
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';';
$output[] = '';
// Data in chunks
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
$rowCount = (int) $db->loadResult();
if ($rowCount === 0) {
$output[] = '-- (empty table)';
$output[] = '';
return implode("\n", $output);
}
$chunkSize = 500;
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
$db->setQuery(
$db->getQuery(true)->select('*')->from($db->quoteName($table)),
$offset,
$chunkSize
);
$rows = $db->loadAssocList();
if (empty($rows)) {
break;
}
foreach ($rows as $row) {
$values = [];
foreach ($row as $value) {
$values[] = $value === null ? 'NULL' : $db->quote($value);
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
$output[] = 'INSERT INTO ' . $db->quoteName($table)
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ');';
}
}
$output[] = '';
return implode("\n", $output);
}
/**
* Get site tables (with prefix), excluding filtered tables.
*/
private function getSiteTables(array $excludeTables): array
{
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$tables = [];
foreach ($db->getTableList() as $table) {
if (!str_starts_with($table, $prefix)) {
continue;
}
$abstractName = '#__' . substr($table, strlen($prefix));
$excluded = false;
foreach ($excludeTables as $pattern) {
if ($pattern === $abstractName || $pattern === $table) {
$excluded = true;
break;
}
}
if (!$excluded) {
$tables[] = $table;
}
}
return $tables;
}
private function parseNewlineList(string $text): array
{
if (empty($text)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode("\n", str_replace("\r", '', $text))),
fn($line) => $line !== ''
));
}
}
@@ -0,0 +1,181 @@
<?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
*
* Manages state for AJAX step-based backups.
*
* On shared hosting where max_execution_time cannot be overridden,
* the backup runs as a series of small AJAX requests. Each request
* loads the session, does one chunk of work, saves state, and returns.
* The browser JS fires the next step automatically.
*
* Phases: init → database → files → finalize → upload → complete
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
class SteppedSession
{
public string $sessionId;
public string $phase = 'init';
public int $recordId = 0;
public int $profileId = 0;
public string $archivePath = '';
public string $archiveName = '';
public string $description = '';
public string $origin = 'backend';
// Database phase tracking
public array $tables = [];
public int $tableIndex = 0;
public int $tablesCount = 0;
public int $dbSize = 0;
// Files phase tracking
public array $fileBatches = [];
public int $batchIndex = 0;
public int $filesCount = 0;
public int $batchSize = 200;
// Profile settings (cached so we don't re-query each step)
public string $backupType = 'full';
public string $backupDir = '';
public array $excludeDirs = [];
public array $excludeFiles = [];
public array $excludeTables = [];
public string $remoteStorage = 'none';
public bool $includeKickstart = false;
public bool $remoteKeepLocal = true;
// Progress
public int $totalSteps = 0;
public int $currentStep = 0;
public string $statusMessage = '';
public array $log = [];
private static function getSessionDir(): string
{
$dir = JPATH_ROOT . '/tmp/mokobackup-sessions';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $dir;
}
private static function getSessionPath(string $sessionId): string
{
// Sanitize session ID to prevent path traversal
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
return self::getSessionDir() . '/' . $safe . '.json';
}
/**
* Create a new session.
*/
public static function create(): self
{
$session = new self();
$session->sessionId = 'mb_' . bin2hex(random_bytes(8));
return $session;
}
/**
* Load an existing session from disk.
*/
public static function load(string $sessionId): ?self
{
$path = self::getSessionPath($sessionId);
if (!is_file($path)) {
return null;
}
$data = json_decode(file_get_contents($path), true);
if (!$data) {
return null;
}
$session = new self();
foreach ($data as $key => $value) {
if (property_exists($session, $key)) {
$session->$key = $value;
}
}
return $session;
}
/**
* Save session state to disk.
*/
public function save(): void
{
$path = self::getSessionPath($this->sessionId);
file_put_contents($path, json_encode(get_object_vars($this), JSON_PRETTY_PRINT));
}
/**
* Delete session file.
*/
public function destroy(): void
{
$path = self::getSessionPath($this->sessionId);
if (is_file($path)) {
@unlink($path);
}
}
/**
* Add a log entry.
*/
public function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
}
/**
* Calculate progress percentage.
*/
public function getProgress(): int
{
if ($this->totalSteps <= 0) {
return 0;
}
return min(100, (int) round(($this->currentStep / $this->totalSteps) * 100));
}
/**
* Clean up old session files (older than 24 hours).
*/
public static function cleanupOldSessions(): void
{
$dir = self::getSessionDir();
if (!is_dir($dir)) {
return;
}
$cutoff = time() - 86400;
foreach (glob($dir . '/mb_*.json') as $file) {
if (filemtime($file) < $cutoff) {
@unlink($file);
}
}
}
}
@@ -14,9 +14,13 @@ use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.multiselect');
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false);
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
?>
@@ -129,3 +133,120 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
</form>
<!-- Stepped Backup Modal (for shared hosting) -->
<div id="mokobackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="mb-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="mb-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
<script>
(function() {
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
// Override the toolbar "Backup Now" button to use stepped backup
document.addEventListener('DOMContentLoaded', function() {
// Find the backup toolbar button and override it
const toolbarBtn = document.querySelector('[onclick*="backups.start"], .button-download');
if (toolbarBtn) {
toolbarBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
startSteppedBackup();
return false;
}, true);
}
});
function showModal() {
document.getElementById('mokobackup-modal').style.display = 'block';
}
function hideModal() {
document.getElementById('mokobackup-modal').style.display = 'none';
}
function updateProgress(progress, message, phase) {
const bar = document.getElementById('mb-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-status').textContent = message;
document.getElementById('mb-phase').textContent = 'Phase: ' + phase;
}
async function postAjax(params) {
const form = new URLSearchParams();
form.append(TOKEN_NAME, '1');
for (const [k, v] of Object.entries(params)) {
form.append(k, v);
}
const res = await fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return res.json();
}
async function startSteppedBackup() {
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
// Init
const initResult = await postAjax({
task: 'ajax.init',
profile_id: '1'
});
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 3000);
return;
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
// Run steps until done
let done = false;
while (!done) {
const stepResult = await postAjax({
task: 'ajax.step',
session_id: sessionId
});
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
// Complete
document.getElementById('mb-modal-title').textContent = 'Backup Complete';
setTimeout(function() {
hideModal();
location.reload();
}, 2000);
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
}
}
// Expose for toolbar button
window.mokobackupStart = startSteppedBackup;
})();
</script>