dae30161ae
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: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 22s
New #__mokosuitebackup_remotes table stores remote destinations with JSON params per type (SFTP/S3/GDrive/FTP). Each profile can have multiple enabled destinations — the engine uploads to all of them. Database: - New table with profile_id FK, type, enabled, params JSON, ordering - Migration auto-converts existing profile remote columns to new table - RemoteTable, RemoteModel, RemotesModel classes Engine: - BackupEngine: loadRemoteDestinations() + createUploaderFromParams() iterates all enabled remotes, falls back to legacy columns - SteppedBackupEngine: one upload step per remote destination, persisted via session.remoteDestinations + remoteIndex - Local copy only deleted when ALL uploads succeed UI: - Profile edit: "Remote Destinations" linked table with AJAX CRUD - Add/edit modal with type selector showing dynamic fields - Toggle enabled/disabled, delete with confirmation - Legacy fields hidden when remotes configured, shown as fallback - Secrets masked in responses, merged from DB on save Closes #97
886 lines
29 KiB
PHP
886 lines
29 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
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\Component\MokoSuiteBackup\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
|
|
{
|
|
// Run pre-flight checks before creating any backup record
|
|
$preflight = new PreflightCheck();
|
|
$preflightResult = $preflight->run($profileId);
|
|
|
|
if (!$preflightResult['pass']) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
|
|
'warnings' => $preflightResult['warnings'],
|
|
];
|
|
}
|
|
|
|
// Override PHP limits for long-running backup operations
|
|
$this->overridePhpLimits();
|
|
|
|
$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 ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
|
|
}
|
|
|
|
// Log any preflight warnings
|
|
foreach ($preflightResult['warnings'] as $warning) {
|
|
$this->log('PREFLIGHT WARNING: ' . $warning);
|
|
}
|
|
|
|
// 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, 'warnings' => $preflightResult['warnings']];
|
|
}
|
|
|
|
// Create backup record
|
|
$now = date('Y-m-d H:i:s');
|
|
$tag = $resolver->getTag();
|
|
$archiveFormat = $profile->archive_format ?? 'zip';
|
|
$archiveName = '';
|
|
$archiver = $this->createArchiver($archiveFormat);
|
|
|
|
// Pass encryption password to 7z archiver (handles it natively via -p flag)
|
|
if ($archiver instanceof SevenZipArchiver && !empty($profile->encryption_password)) {
|
|
$archiver->setEncryptionPassword($profile->encryption_password);
|
|
}
|
|
|
|
$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('#__mokosuitebackup_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)
|
|
// Streams to a temp file to avoid loading the entire dump into RAM
|
|
$sqlTempFile = '';
|
|
|
|
if ($profile->backup_type !== 'files') {
|
|
$this->log('Starting database dump...');
|
|
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
|
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
|
|
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
|
|
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
|
|
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
|
|
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
|
|
|
|
if ($sanitizePasswords) {
|
|
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
|
|
}
|
|
|
|
if ($sanitizeEmails) {
|
|
$this->log('User emails will be sanitized');
|
|
}
|
|
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
|
$archiver->addFile($sqlTempFile, 'database.sql');
|
|
$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)) {
|
|
$skippedFiles++;
|
|
continue;
|
|
}
|
|
|
|
// Store configuration.php as .bak with credentials stripped.
|
|
// The restore process rebuilds a fresh configuration.php
|
|
// from user input + non-sensitive values from the .bak.
|
|
if ($relativePath === 'configuration.php') {
|
|
$sanitized = self::sanitizeConfiguration($fullPath);
|
|
$archiver->addFromString('configuration.php.bak', $sanitized);
|
|
$this->log('configuration.php saved as .bak (credentials stripped)');
|
|
} else {
|
|
$archiver->addFile($fullPath, $relativePath);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
// Clean up temp SQL file (no longer needed after archive is closed)
|
|
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
|
|
@unlink($sqlTempFile);
|
|
}
|
|
|
|
// Step 1.5: Apply AES-256 encryption (if configured)
|
|
$encryptionPassword = $profile->encryption_password ?? '';
|
|
|
|
if (!empty($encryptionPassword)) {
|
|
if ($archiveFormat === 'zip') {
|
|
$this->log('Encrypting archive with AES-256...');
|
|
$this->encryptArchive($archivePath, $encryptionPassword);
|
|
$this->log('Archive encrypted');
|
|
} elseif ($archiveFormat === '7z') {
|
|
$this->log('Archive encrypted with AES-256 (7z native encryption)');
|
|
} else {
|
|
$this->log('WARNING: AES-256 encryption only supported for ZIP and 7z archives — skipping encryption');
|
|
}
|
|
}
|
|
|
|
// 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'));
|
|
|
|
// Verify archive integrity
|
|
$this->log('Verifying archive integrity...');
|
|
$this->verifyArchive($archivePath, $profile->backup_type);
|
|
$this->log('Archive integrity verified');
|
|
|
|
// Step 2.5: MokoRestore script (if enabled)
|
|
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
|
$restoreScriptPath = '';
|
|
|
|
if ($mokoRestoreMode === '1') {
|
|
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
|
|
$this->log('Wrapping with MokoRestore script...');
|
|
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
|
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
|
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
|
|
|
if (is_file($archivePath) && !unlink($archivePath)) {
|
|
$this->log('WARNING: Could not remove pre-wrap archive');
|
|
}
|
|
rename($mokoRestorePath, $archivePath);
|
|
$totalSize = filesize($archivePath);
|
|
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
|
$checksum = hash_file('sha256', $archivePath);
|
|
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
|
$this->log('SHA-256 (wrapped): ' . $checksum);
|
|
} elseif ($mokoRestoreMode === 'standalone') {
|
|
// Standalone mode: restore.php as a separate file next to the backup ZIP
|
|
$this->log('Generating standalone restore.php...');
|
|
$restoreScriptPath = $this->backupDir . '/restore.php';
|
|
MokoRestore::generateStandalone($restoreScriptPath);
|
|
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
|
}
|
|
|
|
$remoteFilename = '';
|
|
$uploadFailed = false;
|
|
|
|
/* Step 3: Remote upload — iterate all enabled destinations */
|
|
$remotes = $this->loadRemoteDestinations($db, $profileId);
|
|
|
|
if (!empty($remotes)) {
|
|
foreach ($remotes as $remote) {
|
|
try {
|
|
$this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...');
|
|
$params = json_decode($remote->params, true) ?: [];
|
|
$uploader = $this->createUploaderFromParams($remote->type, $params);
|
|
$result = $uploader->upload($archivePath, $archiveName);
|
|
|
|
if ($result['success']) {
|
|
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
|
$this->log(' Upload complete: ' . $result['message']);
|
|
|
|
/* Upload standalone restore.php if in standalone mode */
|
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
|
$uploader->upload($restoreScriptPath, 'restore.php');
|
|
}
|
|
} else {
|
|
$uploadFailed = true;
|
|
$this->log(' WARNING: Upload failed: ' . $result['message']);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$uploadFailed = true;
|
|
$this->log(' WARNING: Upload exception: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/* Delete local copy only when ALL remotes succeeded and profile says so */
|
|
if (!$uploadFailed && empty($profile->remote_keep_local) && is_file($archivePath)) {
|
|
@unlink($archivePath);
|
|
$this->log('Local copy removed (remote_keep_local = off)');
|
|
}
|
|
} else {
|
|
/* Backward-compat: fall back to legacy single-remote column */
|
|
$remoteStorage = $profile->remote_storage ?? 'none';
|
|
|
|
if ($remoteStorage !== 'none') {
|
|
try {
|
|
$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']);
|
|
|
|
// Upload standalone restore.php alongside the backup if in standalone mode
|
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
|
$this->log('Uploading standalone restore.php...');
|
|
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
|
|
|
if ($restoreUpload['success']) {
|
|
$this->log('Standalone restore.php uploaded');
|
|
} else {
|
|
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['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 {
|
|
$uploadFailed = true;
|
|
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
|
$this->log('Local backup is preserved.');
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$uploadFailed = true;
|
|
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
|
$this->log('Local backup is preserved.');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write log file alongside the archive
|
|
$logContent = implode("\n", $this->log);
|
|
$logPath = preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
|
|
if (@file_put_contents($logPath, $logContent) === false) {
|
|
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
|
|
}
|
|
|
|
// Final record update (includes fields needed by NotificationSender)
|
|
$update = (object) [
|
|
'id' => $recordId,
|
|
'status' => 'complete',
|
|
'description' => $description,
|
|
'backup_type' => $profile->backup_type,
|
|
'archivename' => $archiveName,
|
|
'origin' => $origin,
|
|
'backupstart' => $now,
|
|
'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('#__mokosuitebackup_records', $update, 'id');
|
|
|
|
// Send success notification (backup completed, even if upload failed)
|
|
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
|
|
|
|
// If remote upload failed, also send a failure notification for the upload
|
|
if ($uploadFailed) {
|
|
NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . 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,
|
|
'warnings' => $preflightResult['warnings'],
|
|
];
|
|
} catch (\Throwable $e) {
|
|
$this->log('FATAL: ' . $e->getMessage());
|
|
|
|
// Clean up temp SQL file on failure
|
|
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
|
|
@unlink($sqlTempFile);
|
|
}
|
|
|
|
// If encryption was intended and failed, remove the plaintext archive
|
|
if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) {
|
|
@unlink($archivePath);
|
|
$this->log('Plaintext archive removed after encryption failure');
|
|
}
|
|
|
|
$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'),
|
|
'total_size' => 0,
|
|
'files_count' => 0,
|
|
'tables_count' => 0,
|
|
'remote_filename' => '',
|
|
'log' => implode("\n", $this->log),
|
|
];
|
|
|
|
$db->updateObject('#__mokosuitebackup_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, 'warnings' => $preflightResult['warnings'] ?? []];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
'7z' => new SevenZipArchiver(),
|
|
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create the appropriate remote uploader based on the storage type.
|
|
* Legacy method — used by backward-compat fallback when remotes table
|
|
* does not exist.
|
|
*/
|
|
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
|
{
|
|
return match ($type) {
|
|
'ftp' => new FtpUploader($profile),
|
|
'sftp' => new SftpUploader($profile),
|
|
'google_drive' => new GoogleDriveUploader($profile),
|
|
's3' => new S3Uploader($profile),
|
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a remote uploader from JSON params (multi-remote destinations).
|
|
*
|
|
* Builds a fake profile-like object from the params array so the existing
|
|
* uploader constructors work without modification.
|
|
*
|
|
* @param string $type Remote type: ftp, sftp, s3, google_drive
|
|
* @param array $params Key-value params decoded from the remote's JSON
|
|
*
|
|
* @return RemoteUploaderInterface
|
|
*/
|
|
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
|
{
|
|
$fake = (object) $params;
|
|
|
|
return match ($type) {
|
|
'ftp' => new FtpUploader($fake),
|
|
'sftp' => new SftpUploader($fake),
|
|
'google_drive' => new GoogleDriveUploader($fake),
|
|
's3' => new S3Uploader($fake),
|
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load enabled remote destinations for a profile from the remotes table.
|
|
*
|
|
* Returns an empty array when the table does not exist (pre-migration)
|
|
* so the caller can fall back to the legacy single-remote column.
|
|
*
|
|
* @param object $db Database driver
|
|
* @param int $profileId Profile ID
|
|
*
|
|
* @return object[] Array of remote destination rows
|
|
*/
|
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
|
{
|
|
try {
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
|
->where($db->quoteName('enabled') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
} catch (\Throwable $e) {
|
|
// Table does not exist yet (pre-migration) — fall back to legacy
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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('#__mokosuitebackup_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) {
|
|
$this->log('WARNING: Could not read file at index ' . $i . ' during encryption — file may remain unencrypted');
|
|
continue;
|
|
}
|
|
|
|
$zip->setEncryptionName($name, \ZipArchive::EM_AES_256);
|
|
}
|
|
|
|
$zip->close();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION));
|
|
|
|
// Detect tar.gz (pathinfo only returns 'gz')
|
|
if ($extension === 'gz' && str_ends_with(strtolower($archivePath), '.tar.gz')) {
|
|
$this->verifyTarGzArchive($archivePath);
|
|
|
|
return;
|
|
}
|
|
|
|
// 7z verification via CLI
|
|
if ($extension === '7z') {
|
|
$this->verify7zArchive($archivePath);
|
|
|
|
return;
|
|
}
|
|
|
|
// ZIP verification
|
|
$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();
|
|
}
|
|
|
|
/**
|
|
* Verify a tar.gz archive can be opened and iterated.
|
|
*
|
|
* @param string $archivePath Absolute path to the .tar.gz file
|
|
*
|
|
* @throws \RuntimeException If the archive fails verification
|
|
*/
|
|
private function verifyTarGzArchive(string $archivePath): void
|
|
{
|
|
try {
|
|
$phar = new \PharData($archivePath);
|
|
$count = 0;
|
|
|
|
foreach ($phar as $entry) {
|
|
// Spot-check: verify at least the first entry is accessible
|
|
$entry->getFilename();
|
|
$count++;
|
|
break;
|
|
}
|
|
|
|
if ($count === 0) {
|
|
throw new \RuntimeException('Archive integrity check failed: tar.gz archive contains no entries');
|
|
}
|
|
} catch (\RuntimeException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
throw new \RuntimeException('Archive integrity check failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a 7z archive using the CLI binary.
|
|
*
|
|
* @param string $archivePath Absolute path to the .7z file
|
|
*
|
|
* @throws \RuntimeException If the archive fails verification
|
|
*/
|
|
private function verify7zArchive(string $archivePath): void
|
|
{
|
|
// Test the archive with 7z t (test integrity)
|
|
$candidates = PHP_OS_FAMILY === 'Windows'
|
|
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
|
|
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
|
|
|
|
$binary = null;
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
|
|
if (is_file($candidate) && is_executable($candidate)) {
|
|
$binary = $candidate;
|
|
break;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$whichCmd = PHP_OS_FAMILY === 'Windows'
|
|
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
|
|
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
|
|
|
|
$result = trim((string) shell_exec($whichCmd));
|
|
|
|
if ($result !== '' && is_executable($result)) {
|
|
$binary = $result;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($binary === null) {
|
|
// Cannot verify without the binary — log warning but don't fail
|
|
$this->log('WARNING: Cannot verify 7z archive (7z binary not found for test)');
|
|
|
|
return;
|
|
}
|
|
|
|
$cmd = escapeshellarg($binary) . ' t ' . escapeshellarg($archivePath) . ' -y 2>&1';
|
|
$output = [];
|
|
$exitCode = 0;
|
|
exec($cmd, $output, $exitCode);
|
|
|
|
if ($exitCode !== 0) {
|
|
throw new \RuntimeException(
|
|
'Archive integrity check failed: 7z test exited with code ' . $exitCode
|
|
. ': ' . implode("\n", array_slice($output, -5))
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dispatch the onMokoSuiteBackupAfterRun 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('onMokoSuiteBackupAfterRun', [
|
|
'success' => $success,
|
|
'record_id' => $recordId,
|
|
'description' => $description,
|
|
'profile_id' => $profileId,
|
|
'origin' => $origin,
|
|
]);
|
|
|
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRun', $event);
|
|
} catch (\Throwable $e) {
|
|
// Never let a listener failure break the backup result, but log it
|
|
error_log('MokoSuiteBackup: onAfterRun listener error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize configuration.php by replacing sensitive field values with
|
|
* [SANITIZED:fieldname] placeholders. Non-sensitive fields (sitename,
|
|
* debug, cache, SEF, etc.) are preserved as-is.
|
|
*
|
|
* @param string $path Absolute path to configuration.php
|
|
*
|
|
* @return string Sanitized file contents
|
|
*/
|
|
public static function sanitizeConfiguration(string $path): string
|
|
{
|
|
$content = file_get_contents($path);
|
|
|
|
if ($content === false) {
|
|
error_log('MokoSuiteBackup: sanitizeConfiguration() failed to read: ' . $path);
|
|
|
|
return '';
|
|
}
|
|
|
|
// Fields whose values must be replaced with placeholders.
|
|
// Grouped by category for maintainability.
|
|
$sensitiveFields = [
|
|
// Database
|
|
'host', 'user', 'password', 'db',
|
|
// Security
|
|
'secret',
|
|
// SMTP
|
|
'smtpuser', 'smtppass', 'smtphost',
|
|
// Proxy
|
|
'proxy_user', 'proxy_pass',
|
|
// Redis
|
|
'redis_server_auth', 'session_redis_server_auth',
|
|
// Database TLS
|
|
'dbsslkey', 'dbsslcert', 'dbsslca',
|
|
];
|
|
|
|
foreach ($sensitiveFields as $field) {
|
|
// Match: public $field = 'value'; (single-quoted)
|
|
$content = preg_replace(
|
|
'/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*\').*?(\';)/m',
|
|
'$1[SANITIZED:' . $field . ']$2',
|
|
$content
|
|
);
|
|
// Match: public $field = "value"; (double-quoted)
|
|
$content = preg_replace(
|
|
'/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*").*?("\s*;)/m',
|
|
'$1[SANITIZED:' . $field . ']$2',
|
|
$content
|
|
);
|
|
}
|
|
|
|
return $content;
|
|
}
|
|
|
|
private function log(string $message): void
|
|
{
|
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
|
}
|
|
}
|