814d1b147c
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
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
- Create BackupDirectory utility class with centralized: - DEFAULT_RELATIVE constant and PLACEHOLDER constant - resolve() — path resolution with [DEFAULT_DIR] and relative path handling - hasPlaceholders() — check for unresolved placeholder tokens - isWebAccessible() — web-root boundary check - protect() — .htaccess and index.html creation with error logging - ensureReady() — mkdir + protect in one call - parseNewlineList() — newline-separated text parsing - logPathFromArchive() — derive .log path from archive path - Remove duplicated methods from BackupEngine, SteppedBackupEngine, ProfileTable, AjaxController, and DashboardModel - All consumers now use BackupDirectory static methods - Net reduction: ~180 lines of duplicated code eliminated
502 lines
16 KiB
PHP
502 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoJoomBackup
|
|
* @subpackage com_mokojoombackup
|
|
* @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
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
|
use Joomla\Event\Event;
|
|
|
|
class BackupEngine
|
|
{
|
|
private string $backupDir;
|
|
private array $log = [];
|
|
|
|
/**
|
|
* Run a backup using the specified profile.
|
|
*
|
|
* @param int $profileId Profile ID to use
|
|
* @param string $description Human-readable description
|
|
* @param string $origin Origin: backend, cli, api, scheduled
|
|
*
|
|
* @return array{success: bool, message: string, record_id?: int}
|
|
*/
|
|
public function run(int $profileId, string $description, string $origin = 'backend'): array
|
|
{
|
|
// Override PHP limits for long-running backup operations
|
|
$this->overridePhpLimits();
|
|
|
|
// Verify required extensions
|
|
$extCheck = $this->checkRequiredExtensions();
|
|
|
|
if ($extCheck !== true) {
|
|
return ['success' => false, 'message' => $extCheck];
|
|
}
|
|
|
|
$db = Factory::getDbo();
|
|
|
|
// Load profile
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokojoombackup_profiles'))
|
|
->where($db->quoteName('id') . ' = ' . $profileId);
|
|
$db->setQuery($query);
|
|
$profile = $db->loadObject();
|
|
|
|
if (!$profile) {
|
|
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
|
|
}
|
|
|
|
// Read settings directly from profile columns
|
|
$excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? '');
|
|
$excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
|
|
$excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
|
|
|
// Resolve placeholders in directory and filename
|
|
$resolver = new PlaceholderResolver($profile);
|
|
|
|
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
|
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
|
|
|
if (!BackupDirectory::ensureReady($this->backupDir)) {
|
|
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
|
|
}
|
|
|
|
// Create backup record
|
|
$now = date('Y-m-d H:i:s');
|
|
$tag = $resolver->getTag();
|
|
$archiveFormat = $profile->archive_format ?? 'zip';
|
|
$archiver = $this->createArchiver($archiveFormat);
|
|
$archiveExt = $archiver->getExtension();
|
|
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
|
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
|
|
|
if (empty($description)) {
|
|
$description = $profile->title . ' — ' . $now;
|
|
}
|
|
|
|
$record = (object) [
|
|
'profile_id' => $profileId,
|
|
'description' => $description,
|
|
'status' => 'running',
|
|
'origin' => $origin,
|
|
'backup_type' => $profile->backup_type,
|
|
'archivename' => $archiveName,
|
|
'absolute_path' => $this->backupDir . '/' . $archiveName,
|
|
'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('#__mokojoombackup_records', $record, 'id');
|
|
$recordId = $record->id;
|
|
|
|
try {
|
|
$this->log('Backup started: ' . $description);
|
|
$archivePath = $this->backupDir . '/' . $archiveName;
|
|
|
|
// Create archive
|
|
$archiver->open($archivePath);
|
|
|
|
$dbSize = 0;
|
|
$filesCount = 0;
|
|
$tablesCount = 0;
|
|
|
|
// Step 1: Database dump (unless files-only)
|
|
if ($profile->backup_type !== 'files') {
|
|
$this->log('Starting database dump...');
|
|
$dumper = new DatabaseDumper($excludeTables);
|
|
$sqlDump = $dumper->dump();
|
|
$archiver->addFromString('database.sql', $sqlDump);
|
|
$dbSize = strlen($sqlDump);
|
|
$tablesCount = $dumper->getTablesCount();
|
|
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
|
|
}
|
|
|
|
// Step 2: Files (unless database-only)
|
|
$manifest = [];
|
|
|
|
if ($profile->backup_type !== 'database') {
|
|
$this->log('Starting file scan...');
|
|
$scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles);
|
|
$allFiles = $scanner->scan();
|
|
|
|
// Differential: only include changed files
|
|
if ($profile->backup_type === 'differential') {
|
|
$baseManifest = $this->loadBaseManifest($db, $profileId);
|
|
|
|
if (empty($baseManifest)) {
|
|
$this->log('No base full backup found — running full backup instead');
|
|
$filesToBackup = $allFiles;
|
|
} else {
|
|
$filesToBackup = DifferentialScanner::getChangedFiles($allFiles, $baseManifest, JPATH_ROOT);
|
|
$this->log('Differential: ' . count($filesToBackup) . ' changed files out of ' . count($allFiles) . ' total');
|
|
}
|
|
} else {
|
|
$filesToBackup = $allFiles;
|
|
}
|
|
|
|
$filesCount = count($filesToBackup);
|
|
$this->log('Backing up ' . $filesCount . ' files');
|
|
|
|
$skippedFiles = 0;
|
|
|
|
foreach ($filesToBackup as $relativePath) {
|
|
$fullPath = JPATH_ROOT . '/' . $relativePath;
|
|
|
|
if (is_file($fullPath) && is_readable($fullPath)) {
|
|
$archiver->addFile($fullPath, $relativePath);
|
|
} else {
|
|
$skippedFiles++;
|
|
}
|
|
}
|
|
|
|
if ($skippedFiles > 0) {
|
|
$this->log('WARNING: ' . $skippedFiles . ' files skipped (not readable or missing)');
|
|
}
|
|
|
|
$this->log('Files added to archive');
|
|
|
|
// Build manifest for full/differential backups (used by future differentials)
|
|
if ($profile->backup_type === 'full' || ($profile->backup_type === 'differential' && empty($baseManifest))) {
|
|
$manifest = DifferentialScanner::buildManifest($allFiles, JPATH_ROOT);
|
|
$this->log('File manifest built: ' . count($manifest) . ' entries');
|
|
}
|
|
}
|
|
|
|
$archiver->close();
|
|
|
|
// Step 1.5: Apply AES-256 encryption (if configured)
|
|
$encryptionPassword = $profile->encryption_password ?? '';
|
|
|
|
if (!empty($encryptionPassword)) {
|
|
if ($archiveFormat !== 'zip') {
|
|
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
|
|
} else {
|
|
$this->log('Encrypting archive with AES-256...');
|
|
$this->encryptArchive($archivePath, $encryptionPassword);
|
|
$this->log('Archive encrypted');
|
|
}
|
|
}
|
|
|
|
// Record archive size and compute checksum (after encryption)
|
|
$totalSize = file_exists($archivePath) ? filesize($archivePath) : 0;
|
|
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
|
$checksum = is_file($archivePath) ? hash_file('sha256', $archivePath) : '';
|
|
$this->log('Archive created: ' . $sizeHuman);
|
|
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
|
|
|
|
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
|
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
|
|
|
if ($includeMokoRestore) {
|
|
$this->log('Wrapping with MokoRestore script...');
|
|
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
|
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
|
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
|
|
|
// Replace the original archive with the wrapped one
|
|
@unlink($archivePath);
|
|
rename($mokoRestorePath, $archivePath);
|
|
$totalSize = filesize($archivePath);
|
|
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
|
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
|
}
|
|
|
|
$remoteFilename = '';
|
|
|
|
// Step 3: Remote upload (if configured)
|
|
$remoteStorage = $profile->remote_storage ?? 'none';
|
|
|
|
if ($remoteStorage !== 'none') {
|
|
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
|
$uploader = $this->createUploader($remoteStorage, $profile);
|
|
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
|
|
|
if ($uploadResult['success']) {
|
|
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
|
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
|
|
|
// Delete local copy if configured
|
|
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
|
@unlink($archivePath);
|
|
$this->log('Local copy removed (remote_keep_local = off)');
|
|
}
|
|
} else {
|
|
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
|
$this->log('Local backup is preserved.');
|
|
}
|
|
}
|
|
|
|
// Write log file alongside the archive
|
|
$logContent = implode("\n", $this->log);
|
|
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
|
if (@file_put_contents($logPath, $logContent) === false) {
|
|
error_log('MokoJoomBackup: Could not write log file: ' . $logPath);
|
|
}
|
|
|
|
// Final record update
|
|
$update = (object) [
|
|
'id' => $recordId,
|
|
'status' => 'complete',
|
|
'total_size' => $totalSize,
|
|
'db_size' => $dbSize,
|
|
'files_count' => $filesCount,
|
|
'tables_count' => $tablesCount,
|
|
'backupend' => date('Y-m-d H:i:s'),
|
|
'filesexist' => is_file($archivePath) ? 1 : 0,
|
|
'remote_filename' => $remoteFilename,
|
|
'checksum' => $checksum,
|
|
'manifest' => !empty($manifest) ? json_encode($manifest) : '',
|
|
'log' => $logContent,
|
|
];
|
|
|
|
$db->updateObject('#__mokojoombackup_records', $update, 'id');
|
|
|
|
// Send success notification
|
|
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
|
|
|
|
// Dispatch event for actionlog and other listeners
|
|
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
|
|
'record_id' => $recordId,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
$this->log('FATAL: ' . $e->getMessage());
|
|
|
|
$update = (object) [
|
|
'id' => $recordId,
|
|
'status' => 'fail',
|
|
'description' => $description ?: '',
|
|
'backup_type' => $profile->backup_type ?? 'full',
|
|
'origin' => $origin,
|
|
'archivename' => $archiveName,
|
|
'backupstart' => $now ?? date('Y-m-d H:i:s'),
|
|
'backupend' => date('Y-m-d H:i:s'),
|
|
'log' => implode("\n", $this->log),
|
|
];
|
|
|
|
$db->updateObject('#__mokojoombackup_records', $update, 'id');
|
|
|
|
// Send failure notification
|
|
NotificationSender::send($profile, $update, false, implode("\n", $this->log));
|
|
|
|
// Dispatch event for actionlog and other listeners
|
|
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
|
|
|
|
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Override PHP execution limits for backup operations.
|
|
* Attempts multiple methods since some hosts restrict ini_set.
|
|
*/
|
|
private function overridePhpLimits(): void
|
|
{
|
|
// Remove execution time limit (0 = unlimited)
|
|
@set_time_limit(0);
|
|
@ini_set('max_execution_time', '0');
|
|
|
|
// Increase memory limit for large sites
|
|
$currentMemory = $this->parseBytes(ini_get('memory_limit'));
|
|
|
|
if ($currentMemory > 0 && $currentMemory < 512 * 1024 * 1024) {
|
|
@ini_set('memory_limit', '512M');
|
|
}
|
|
|
|
// Disable output buffering to prevent memory buildup
|
|
while (@ob_end_clean()) {
|
|
// flush all output buffers
|
|
}
|
|
|
|
// Prevent browser/proxy timeout by disabling compression
|
|
@ini_set('zlib.output_compression', 'Off');
|
|
|
|
// Ignore user abort so backup completes even if browser closes
|
|
@ignore_user_abort(true);
|
|
}
|
|
|
|
/**
|
|
* Parse a PHP ini byte value (e.g. "128M") into bytes.
|
|
*/
|
|
private function parseBytes(string $value): int
|
|
{
|
|
$value = trim($value);
|
|
|
|
if ($value === '-1' || $value === '') {
|
|
return -1;
|
|
}
|
|
|
|
$unit = strtolower(substr($value, -1));
|
|
$num = (int) $value;
|
|
|
|
return match ($unit) {
|
|
'g' => $num * 1024 * 1024 * 1024,
|
|
'm' => $num * 1024 * 1024,
|
|
'k' => $num * 1024,
|
|
default => $num,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verify required PHP extensions are loaded.
|
|
*
|
|
* @return true|string True if all ok, or error message string
|
|
*/
|
|
private function checkRequiredExtensions(): true|string
|
|
{
|
|
$missing = [];
|
|
|
|
if (!extension_loaded('zip')) {
|
|
$missing[] = 'ext-zip (required for archive creation)';
|
|
}
|
|
|
|
if (!extension_loaded('mbstring') && !function_exists('mb_strlen')) {
|
|
$missing[] = 'ext-mbstring (required for binary-safe operations)';
|
|
}
|
|
|
|
if (!empty($missing)) {
|
|
return 'Missing PHP extensions: ' . implode(', ', $missing);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Create the appropriate archiver based on the archive format.
|
|
*/
|
|
private function createArchiver(string $format): ArchiverInterface
|
|
{
|
|
return match ($format) {
|
|
'zip' => new ZipArchiver(),
|
|
'tar.gz' => new TarGzArchiver(),
|
|
default => new ZipArchiver(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create the appropriate remote uploader based on the storage type.
|
|
*/
|
|
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
|
{
|
|
return match ($type) {
|
|
'ftp' => new FtpUploader($profile),
|
|
'google_drive' => new GoogleDriveUploader($profile),
|
|
's3' => new S3Uploader($profile),
|
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load the file manifest from the most recent full backup for this profile.
|
|
* Used by differential backups to determine which files changed.
|
|
*/
|
|
private function loadBaseManifest(object $db, int $profileId): array
|
|
{
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('manifest'))
|
|
->from($db->quoteName('#__mokojoombackup_records'))
|
|
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
|
->where($db->quoteName('manifest') . ' != ' . $db->quote(''))
|
|
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
|
|
->order($db->quoteName('backupstart') . ' DESC');
|
|
$db->setQuery($query, 0, 1);
|
|
$manifestJson = $db->loadResult();
|
|
|
|
if (empty($manifestJson)) {
|
|
return [];
|
|
}
|
|
|
|
return json_decode($manifestJson, true) ?: [];
|
|
}
|
|
|
|
/**
|
|
* Encrypt a ZIP archive using AES-256.
|
|
*
|
|
* Uses ZipArchive::setEncryptionName() (PHP 7.2+) which produces
|
|
* WinZip-compatible AES-256 encrypted archives. Falls back to
|
|
* re-creating the archive with per-file encryption if needed.
|
|
*/
|
|
private function encryptArchive(string $archivePath, string $password): void
|
|
{
|
|
if (!defined('ZipArchive::EM_AES_256')) {
|
|
throw new \RuntimeException(
|
|
'AES-256 ZIP encryption requires PHP 7.2+ compiled with libzip 1.2.0+. '
|
|
. 'Your PHP installation does not support ZipArchive::EM_AES_256.'
|
|
);
|
|
}
|
|
|
|
$zip = new \ZipArchive();
|
|
|
|
if ($zip->open($archivePath) !== true) {
|
|
throw new \RuntimeException('Cannot open archive for encryption');
|
|
}
|
|
|
|
$zip->setPassword($password);
|
|
|
|
$numFiles = $zip->numFiles;
|
|
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
$name = $zip->getNameIndex($i);
|
|
|
|
if ($name === false) {
|
|
continue;
|
|
}
|
|
|
|
$zip->setEncryptionName($name, \ZipArchive::EM_AES_256);
|
|
}
|
|
|
|
$zip->close();
|
|
}
|
|
|
|
/**
|
|
* Dispatch the onMokoJoomBackupAfterRun event so plugins (actionlog, etc.) can react.
|
|
*/
|
|
private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void
|
|
{
|
|
try {
|
|
$app = Factory::getApplication();
|
|
|
|
$event = new Event('onMokoJoomBackupAfterRun', [
|
|
'success' => $success,
|
|
'record_id' => $recordId,
|
|
'description' => $description,
|
|
'profile_id' => $profileId,
|
|
'origin' => $origin,
|
|
]);
|
|
|
|
$app->getDispatcher()->dispatch('onMokoJoomBackupAfterRun', $event);
|
|
} catch (\Throwable $e) {
|
|
// Never let a listener failure break the backup result, but log it
|
|
error_log('MokoJoomBackup: onAfterRun listener error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function log(string $message): void
|
|
{
|
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
|
}
|
|
}
|