Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php
T
Jonathan Miller 899a33bc58
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 10s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m50s
feat: purge, CPanel module, 7z format, SFTP browser (#119, #105, #122, #98)
#119: Manual purge — toolbar button opens modal with date picker,
AJAX count preview, confirmation before bulk delete.

#105: CPanel admin dashboard module (mod_mokosuitebackup_cpanel) —
backup status, quick action buttons per profile, next scheduled,
stats, and quick links. Registered in package manifest.

#122: 7z archive format via system 7za/7z CLI binary with optional
password encryption. New SevenZipArchiver engine class.

#98: SFTP remote file browser — custom SftpPathField with "Browse
Remote" button, modal directory listing via SSH ls, click to navigate,
double-click to select.

Also: CHANGELOG updated, wiki Home updated, #121 verified (encryption
field already visible in Archive Settings tab).

Closes #119, closes #105, closes #122, closes #98, closes #121
2026-06-23 13:05:42 -05:00

733 lines
22 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
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'error' => true,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
$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, 'warnings' => []];
}
// 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();
$archiveFormat = $profile->archive_format ?? 'zip';
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
// The stepped engine uses ZipArchive batch-by-batch, so only ZIP is
// supported. For 7z / tar.gz the non-stepped BackupEngine must be used.
if ($archiveFormat !== 'zip') {
return [
'error' => true,
'message' => 'The stepped backup engine only supports ZIP format. '
. 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.',
];
}
$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) . ')');
// Log any preflight warnings into the session
foreach ($preflightResult['warnings'] as $warning) {
$session->log('PREFLIGHT WARNING: ' . $warning);
}
$session->statusMessage = 'Initialized — starting backup...';
$session->save();
return [
'session_id' => $session->sessionId,
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
'warnings' => $preflightResult['warnings'],
];
}
/**
* 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
$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)) {
continue;
}
// Store config as .bak with credentials stripped — restore rebuilds it
if (basename($relativePath) === 'configuration.php' && dirname($relativePath) === '.') {
$sanitized = BackupEngine::sanitizeConfiguration($fullPath);
$zip->addFromString('configuration.php.bak', $sanitized);
} else {
$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;
// Verify archive integrity
$session->log('Verifying archive integrity...');
$this->verifyArchive($session->archivePath, $session->backupType);
$session->log('Archive integrity verified');
// 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();
$remoteFilename = '';
$uploadFailed = false;
// Wrapped in its own try-catch so a remote failure does not mark
// the entire backup as failed — the local archive is preserved.
try {
// 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),
'sftp' => new SftpUploader($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);
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 {
$uploadFailed = true;
$session->log('WARNING: Remote upload failed: ' . $result['message']);
$session->log('Local backup is preserved.');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
$session->log('Local backup is preserved.');
}
// 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 = $uploadFailed
? 'Backup complete (remote upload failed — local archive preserved)'
: 'Backup complete';
$this->completeRecord($session, $uploadFailed);
}
/**
* Verify that a backup archive can be opened and contains expected entries.
*
* @param string $archivePath Absolute path to the archive file
* @param string $backupType Backup type: full, database, files, differential
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyArchive(string $archivePath, string $backupType): void
{
if (!is_file($archivePath)) {
throw new \RuntimeException('Archive file does not exist: ' . $archivePath);
}
$zip = new \ZipArchive();
if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) {
throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file');
}
if ($zip->numFiles < 1) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: archive contains no files');
}
// Verify database.sql exists when backup includes database
if ($backupType !== 'files') {
if ($zip->locateName('database.sql') === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive');
}
}
// Spot-check: verify the first entry is readable
$firstName = $zip->getNameIndex(0);
if ($firstName === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: cannot read first entry');
}
$zip->close();
}
/**
* Mark the backup record as complete.
*/
private function completeRecord(SteppedSession $session, bool $uploadFailed = false): 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);
}
$totalSize = is_file($session->archivePath) ? filesize($session->archivePath) : 0;
$checksum = is_file($session->archivePath) ? hash_file('sha256', $session->archivePath) : '';
$update = (object) [
'id' => $session->recordId,
'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => $totalSize,
'checksum' => $checksum,
'log' => $logContent,
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send notifications (email + ntfy)
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
if ($profile) {
$record = (object) [
'id' => $session->recordId,
'description' => $session->description ?? '',
'backup_type' => $session->backupType,
'archivename' => $session->archiveName,
'origin' => $session->origin,
'backupstart' => '',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => $totalSize,
'files_count' => $session->filesCount ?? 0,
'tables_count' => $session->tablesCount ?? 0,
'remote_filename' => '',
];
NotificationSender::send($profile, $record, true, $logContent);
// If remote upload failed, also send a failure notification for the upload
if ($uploadFailed) {
NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent);
}
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
}
}
/**
* Mark the backup record as failed.
*/
private function failRecord(SteppedSession $session, string $error): void
{
$db = Factory::getDbo();
$logContent = implode("\n", $session->log);
$update = (object) [
'id' => $session->recordId,
'status' => 'fail',
'backupend' => date('Y-m-d H:i:s'),
'log' => $logContent,
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send failure notification
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
if ($profile) {
$record = (object) [
'id' => $session->recordId,
'description' => $session->description,
'backup_type' => $session->backupType,
'archivename' => $session->archiveName,
'origin' => $session->origin,
'backupstart' => '',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => 0,
'files_count' => $session->filesCount,
'tables_count' => $session->tablesCount,
'remote_filename' => '',
];
NotificationSender::send($profile, $record, false, $logContent);
}
} catch (\Exception $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine failure notification failed: ' . $e->getMessage());
}
}
/**
* Dump a single table to SQL string.
*/
private function dumpSingleTable(object $db, string $table): string
{
$prefix = $db->getPrefix();
$abstractName = '#__' . substr($table, strlen($prefix));
$output = [];
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $abstractName;
$output[] = '-- --------------------------------------------------------';
$output[] = '';
// CREATE TABLE — replace live prefix with #__
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow();
if (!$createRow || empty($createRow[1])) {
return '';
}
// Replace all occurrences of the live prefix — covers FK REFERENCES too
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
$output[] = $createSql . ';';
$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 `' . $abstractName . '`'
. ' (' . 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;
}
}