Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
T
Jonathan Miller 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
feat: rename mokojoombackup → mokosuitebackup, add [HOME] placeholder for backup directory
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.
2026-06-11 12:24:27 -05:00

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;
}
}