ace33b60fe
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
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
Renames all sub-extensions from mokojoombackup to mokosuitebackup
(package, component, 7 plugins, language files, manifests).
Adds [HOME] placeholder to BackupDirectory and PlaceholderResolver
so users can set backup_dir to [HOME]/backups (outside web root).
Fixes folder browser "access denied" on PHP-FPM shared hosting
where getenv('HOME') returns empty by adding POSIX and JPATH_ROOT
fallback detection.
555 lines
16 KiB
PHP
555 lines
16 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
|
|
*
|
|
* 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\MokoSuiteBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
|
|
|
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('#__mokosuitebackup_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 = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? '');
|
|
$session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
|
|
$session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
|
$session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
|
$session->remoteStorage = $profile->remote_storage ?? 'none';
|
|
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
|
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
|
|
|
// Resolve placeholders in directory and filename
|
|
$resolver = new PlaceholderResolver($profile);
|
|
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
|
|
|
if (!BackupDirectory::ensureReady($backupDir)) {
|
|
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
|
|
}
|
|
|
|
$now = date('Y-m-d H:i:s');
|
|
$tag = $resolver->getTag();
|
|
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
|
$archiveName = $resolver->resolve($nameFormat) . '.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('#__mokosuitebackup_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 = "-- MokoSuiteBackup 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";
|
|
if (file_put_contents($sqlFile, $header) === false) {
|
|
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
|
|
}
|
|
$flags = FILE_APPEND;
|
|
}
|
|
|
|
if (file_put_contents($sqlFile, $sql, $flags) === false) {
|
|
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
|
|
}
|
|
$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 MokoRestore 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)) {
|
|
error_log('MokoSuiteBackup: Could not delete temp SQL file: ' . $sqlFile);
|
|
}
|
|
|
|
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
|
|
|
|
// MokoRestore wrapper
|
|
if ($session->includeMokoRestore) {
|
|
$session->log('Wrapping with MokoRestore script...');
|
|
$mokoRestorePath = $session->archivePath . '.mokorestore.zip';
|
|
MokoRestore::wrap($session->archivePath, $mokoRestorePath);
|
|
@unlink($session->archivePath);
|
|
rename($mokoRestorePath, $session->archivePath);
|
|
$totalSize = filesize($session->archivePath);
|
|
$session->log('MokoRestore 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('#__mokosuitebackup_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('#__mokosuitebackup_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),
|
|
's3' => new S3Uploader($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('#__mokosuitebackup_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();
|
|
$logContent = implode("\n", $session->log);
|
|
|
|
// Write log file alongside the archive
|
|
$logPath = BackupDirectory::logPathFromArchive($session->archivePath);
|
|
if (@file_put_contents($logPath, $logContent) === false) {
|
|
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
|
|
}
|
|
|
|
$update = (object) [
|
|
'id' => $session->recordId,
|
|
'status' => 'complete',
|
|
'backupend' => date('Y-m-d H:i:s'),
|
|
'log' => $logContent,
|
|
];
|
|
|
|
$db->updateObject('#__mokosuitebackup_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('#__mokosuitebackup_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;
|
|
}
|
|
|
|
}
|