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
#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
733 lines
22 KiB
PHP
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;
|
|
}
|
|
|
|
}
|