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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user