Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php
T
Jonathan Miller 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
feat: multi-remote storage — multiple destinations per profile (#97)
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
2026-06-23 16:53:08 -05:00

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